Moteur local de synthèse canonique

Field Horizon

L’unité de travail n’est pas un échange de chat. C’est un cycle : une entrée, un plan de recherche, des preuves sélectionnées, un argument, une critique, un verdict, et un état canonique qui peut être modifié — ou refusé.

Python 3.11+RustSQLite FTS5OllamaFastAPI / OpenAPITauri + ReactPlaywright
Statut
Sources publiées le 2 septembre 2026 / exploitation locale
Code
FH-01
Domaine
Moteur local de synthèse canonique
Porté par
Pomegranate Interactive
Dépôt
github.com/PomegranateComputing/FieldHorizon
Commit
7a4fd60 · 2 septembre 2026
Exécution
Ollama local · SQLite · FastAPI
Écran de commande de Field Horizon : santé du moteur, base, modèle actif, comptes de chunks et pipeline de cycles
Capture réelle de l’application locale : écran de commande, console d’événements et pipeline de l’interprète au canon.
Écran des contradictions de Field Horizon listant les tensions enregistrées entre entrées du canon
Capture réelle : la contradiction est un enregistrement de plein droit, non une erreur à masquer.

Un système local de recherche, de provenance, de rejeu et de synthèse canonique : des corpus hétérogènes sont acquis sous un moteur de droits fondé sur la preuve, indexés pour une recherche hybride, disputés par des agents adversariaux, jugés, puis réintégrés dans un canon que chaque cycle ultérieur peut retrouver et chaque lecteur ultérieur peut tracer.

97Modules Python du paquet moteur
49Opérations OpenAPI sous contrat
15Adaptateurs de source de corpus
1 196Fonctions de test déclarées dans la suite

Profil de mission

  • Acquérir des corpus depuis des catalogues de bibliothèques officiels via des adaptateurs de source typés, jamais par crawl et jamais au-delà d’une authentification.
  • Décider des droits de façon déterministe, à partir de preuves enregistrées, et mettre en quarantaine tout ce qui ne peut être prouvé.
  • Rechercher avec un plan qui nomme ses stratégies plutôt qu’avec un score de similarité qui n’explique rien.
  • Tenir la provenance comme des arêtes interrogeables aux offsets exacts, afin qu’une affirmation puisse être remontée jusqu’aux octets dont elle provient.
  • Enregistrer chaque opération comme un événement append-only, pour qu’un run puisse être rejoué au lieu d’être raconté.

Architecture

État vérifié

Ce qui existe et a été exercé au moment de la rédaction.

  • Les sources sont publiques : 97 modules Python dans le paquet moteur, un cœur Rust, un service FastAPI et un client de bureau Tauri, publiés le 2 septembre 2026.
  • L’acquisition de corpus passe par 15 adaptateurs de source sur des catalogues OPDS, OAI-PMH et SRU, derrière une liste d’hôtes autorisés par source.
  • Le moteur de droits est du code déterministe, jamais un appel de modèle, et il ne peut pas retourner « accept » pour un signal sans preuve enregistrée.
  • La recherche est planifiée sur neuf stratégies déclarées, et chaque stratégie indique si un writer du code la soutient réellement.
  • Les arêtes de provenance couvrent six types d’objets et onze types de relations, portent des intervalles de validité et s’interrogent au lieu de se raconter.
  • Le contrat de service est généré, non écrit à la main : 49 opérations et 87 schémas dans le document OpenAPI, avec des types TypeScript dérivés.
  • La suite déclare 1 196 fonctions de test réparties sur 85 fichiers, dont une suite de parité qui tient les moteurs de pression Rust et Python au même résultat.

Limites assumées

Ce que ce projet n’est pas, écrit ici pour que personne n’ait à le découvrir plus tard.

  • Ce site rapporte des chiffres comptés sur le dépôt publié au commit 7a4fd60. Il n’a pas exécuté la suite de tests, et un test déclaré n’est pas un test qui passe.
  • Le moteur est local d’abord par conception : il joint les modèles via une instance Ollama locale et n’a ni déploiement hébergé, ni modèle multi-utilisateur, ni audit de sécurité indépendant.
  • Rien de ce qu’il produit n’est branché sur une diffusion. C’est un instrument clos, et ouvrir cette frontière serait une décision d’architecture, non un changement de configuration.
  • La qualité de la recherche, celle des verdicts et la valeur d’un fragment synthétisé sont des jugements éditoriaux. Aucune gate de ce système ne les signe.
  • Plusieurs stratégies du planificateur sont à base de règles et délibérément petites. Cela les rend testables exhaustivement ; cela ne les rend pas intelligentes.

L’acquisition est un problème d’adaptateurs

La matière d’un corpus n’arrive pas sous forme de dossier de fichiers. Elle arrive comme des notices de catalogue publiées par des institutions qui emploient chacune un protocole différent : le chemin d’ingestion est donc un registre d’adaptateurs typés, non un scraper. Quinze adaptateurs couvrent les flux OPDS, les entrepôts OAI-PMH et les points SRU, et chacun déclare les capacités qu’il possède réellement au lieu de feindre une interface uniforme qu’il ne peut pas tenir.

Deux règles bornent tout le chemin. Aucune URL trouvée à l’intérieur d’un document téléchargé n’est jamais suivie : seules les URL qu’un adaptateur a extraites d’une notice de catalogue, contre une liste d’hôtes autorisés par source, sont requêtées. Et rien dont les droits sont inconnus n’est indexé.

Les droits sont décidés par la preuve, jamais par un modèle

Le moteur de droits est du code déterministe ordinaire. Il classe un signal de licence en accept, quarantaine ou rejet, et sa dernière garde est celle qui compte : un accept est impossible sans preuve enregistrée de l’endroit où l’assertion de licence a été faite. Une date de publication est un contexte enrichissant, jamais un verdict de domaine public : la durée du droit dépend de la juridiction, de l’édition, de la traduction et de l’appareil éditorial — rien de tout cela ne se règle par une année.

CODE DU DÉPÔTfieldhorizon/corpus/rights.py:488-500@7a4fd609ba51Python
    # The evidence gate. Everything above could pass and this still
    # refuses: an accept with no recorded proof is exactly what rule 8-10
    # forbids, and "the provider's field said so" only counts when the
    # adapter recorded WHERE it said so.
    if not signal.evidence:
        work.add(ReasonCode.EVIDENCE_MISSING)
        return _refuse(Decision.QUARANTINE, normalized, work, profile, signal, checked_at)

    if lic_facts.accept_reason:
        work.add(lic_facts.accept_reason)

    return RightsDecision(
        decision=Decision.ACCEPT,

La garde s’exécute après toutes les autres branches : un signal ayant passé la normalisation de licence, la résolution de portée et la confiance envers le déposant est tout de même rétrogradé en quarantaine si rien n’a enregistré d’où venait l’assertion. C’est ce qui sépare un moteur de droits d’un simple comparateur de chaînes de licence.

La recherche est planifiée, et le plan est typé

La recherche combine SQLite FTS5 sur les textes ingérés, la similarité d’embeddings sur la même matière, un routage orienté par domaines depuis une ontologie déclarée, et une sélection par pertinence marginale maximale sur le canon accepté, afin que les fragments retrouvés soient pertinents pour la requête et dissemblables entre eux. Un planificateur surplombe l’ensemble et émet un plan typé : quelles stratégies ont été retenues, lesquelles étaient disponibles, et pourquoi.

CODE DU DÉPÔTfieldhorizon/retrieval_plan.py:22-40@7a4fd609ba51Python
# The full strategy vocabulary retrieval planner v3 recognizes (Implementation
# Brief III, Phase E). Every one of these has REAL backing in this
# codebase today (see the Phase E dossier's per-strategy grounding) --
# CONTRADICTS (as opposed to OPPOSES) has no writer anywhere in this
# repository and is deliberately absent, not stubbed.
STRATEGY_LEXICAL = "LEXICAL"
STRATEGY_VECTOR = "VECTOR"
STRATEGY_DOMAIN_ROUTING = "DOMAIN_ROUTING"
STRATEGY_ENTITY = "ENTITY"
STRATEGY_MOTIF = "MOTIF"
STRATEGY_CONTRADICTION = "CONTRADICTION"
STRATEGY_CANON_GENEALOGY = "CANON_GENEALOGY"
STRATEGY_SCHOOL = "SCHOOL"
STRATEGY_TEMPORAL = "TEMPORAL"

STRATEGIES = (
    STRATEGY_LEXICAL, STRATEGY_VECTOR, STRATEGY_DOMAIN_ROUTING, STRATEGY_ENTITY, STRATEGY_MOTIF,
    STRATEGY_CONTRADICTION, STRATEGY_CANON_GENEALOGY, STRATEGY_SCHOOL, STRATEGY_TEMPORAL,
)

Le vocabulaire est clos et chaque entrée a un writer derrière elle. Le commentaire est le point intéressant : une stratégie que le code ne peut pas réellement soutenir est absente plutôt que présente en stub, ce qui permet au planificateur de rapporter honnêtement sa disponibilité au lieu de se dégrader en silence.

La provenance est un graphe, pas une note de bas de page

L’origine d’une affirmation est stockée comme des arêtes relationnelles sur des lignes que le système possède déjà — cycles, chunks, entrées structurées, écoles, sources et conciles — avec onze types de relations couvrant sélection, appartenance, remplacement, retrait, réhabilitation, dérivation, extraction, soutien et opposition. Les chunks conservent les offsets exacts de la source : un passage retrouvé se résout donc en une plage d’octets dans un document ingéré précis, non en une citation d’apparence plausible.

CODE DU DÉPÔTfieldhorizon/provenance.py:54-70@7a4fd609ba51Python
@dataclass(frozen=True)
class ProvenanceEdge:
    source_type: str
    source_id: str
    target_type: str
    target_id: str
    relation_type: str
    confidence: float = 1.0
    polarity: float | None = None
    evidence_ref: str | None = None
    provenance_ref: str | None = None
    valid_from: str | None = None
    valid_to: str | None = None
    creation_method: str = CREATION_WRITE_TIME
    review_status: str = "unreviewed"
    id: int | None = None
    recorded_at: str | None = None

Confiance, polarité, référence de preuve et intervalle de validité sont des champs de l’arête, et creation_method distingue une arête écrite au moment de l’opération d’une arête ajoutée par un backfill ultérieur. C’est cette distinction qui rend le graphe interrogeable historiquement sans mentir.

Chaque opération laisse un événement

Cycles, conciles, runs oniriques, ingestions et plans de recherche émettent tous des événements de domaine append-only portant des identifiants de corrélation et de causation à côté d’un identifiant de run. Les manifestes de run lient entrées, contexte de modèle et sorties d’une même exécution, et le journal d’événements s’exporte en JSONL : un run passé se rejoue depuis son enregistrement au lieu de se reconstruire depuis un résumé écrit après coup.

CODE DU DÉPÔTfieldhorizon/events.py:112-128@7a4fd609ba51Python
@dataclass(frozen=True)
class DomainEvent:
    event_id: str
    event_type: str
    event_version: int
    occurred_at: str
    recorded_at: str
    actor: str
    aggregate_type: str
    aggregate_id: str | None
    correlation_id: str
    causation_id: str | None
    run_id: str
    payload: dict
    id: int | None = None
    canon_event_id: int | None = None
    principal_id: str | None = None

L’événement porte à la fois occurred_at et recorded_at, minimum nécessaire pour raisonner sur un système dont l’historique peut être écrit après coup. Tout le reste de l’enregistrement existe pour qu’un rejeu reconstruise la chaîne causale plutôt que l’ordre de l’horloge murale.

Les octets téléchargés sont traités comme hostiles

Tout ce que le chemin d’acquisition télécharge est supposé hostile jusqu’à preuve du contraire. Aucun octet téléchargé n’est exécuté, rendu, ni interprété comme un balisage capable de s’exécuter ; le HTML est réduit en texte par un tokenizer dépourvu de toute notion de script ; les membres d’archive sont contrôlés contre la traversée de chemin et les limites d’expansion avant extraction ; et les métadonnées n’atteignent jamais un shell, une chaîne SQL ou un chemin de fichier sans assainissement.

CODE DU DÉPÔTfieldhorizon/corpus/security.py:264-284@7a4fd609ba51Python
def safe_parse_xml(data: bytes | str, *, max_bytes: int = MAX_XML_BYTES) -> ElementTree.Element:
    """
    Parse XML with external entities and DTDs refused outright.

    Rather than configuring the parser to *resist* entity expansion, the
    declarations are rejected before parsing begins. That is a stricter
    contract and a far easier one to verify: there is no expansion budget
    to tune and no parser-version-dependent behaviour to reason about.
    Legitimate OPDS, OAI-PMH, RDF, SRU, and TEI records do not carry
    DOCTYPEs.
    """
    raw = data.encode("utf-8", errors="replace") if isinstance(data, str) else data

    if len(raw) > max_bytes:
        raise UnsafeXMLError(f"XML payload of {len(raw)} bytes exceeds the {max_bytes}-byte limit")

    head = raw[:8192]
    if _DOCTYPE_RE.search(head):
        raise UnsafeXMLError("Refusing XML containing a DOCTYPE declaration")
    if _ENTITY_RE.search(raw):
        raise UnsafeXMLError("Refusing XML containing an ENTITY declaration")

Le refus est structurel plutôt que défensif : les déclarations d’entités et de DOCTYPE sont rejetées avant même l’analyse, si bien qu’il n’y a aucun budget d’expansion à régler ni comportement dépendant de la version du parseur à considérer. Contrat plus strict que le durcissement d’un parseur, et bien plus simple à tester.

Une seconde implémentation qui n’a pas le droit de diverger

Le graphe de pression ontologique — la structure qui apparie les entrées dont les catégories sont déclarées opposées par l’ontologie — existe deux fois : une fois en Python dans le moteur, une fois en Rust comme cœur indépendant sur le même corpus fusionné et le même fichier d’ontologie. Une suite de parité tient les deux au même résultat, et le cœur Rust se replie bruyamment, non silencieusement, lorsque son binaire n’est pas construit. La frontière Rust existe précisément pour cela : le calcul doit rester déterministe et comparable, pas seulement rapide.

CODE DU DÉPÔTfieldhorizon-rust/src/pressure.rs:39-58@7a4fd609ba51Rust
/// Edges only ever exist between the (~10) category pairs ontology.yaml
/// declares as opposed, so there is no reason to examine all N*(N-1)/2
/// entry pairs: group entries by category first, then cross only the
/// groups an opposition pair actually names (review §4).
pub fn build_pressure_report(
    entries: &[JsonEntry],
    limit: usize,
    oppositions: &OppositionTable,
) -> CoreReport {
    let mut groups: HashMap<String, Vec<&JsonEntry>> = HashMap::new();
    for entry in entries {
        let category = cat(entry);
        if !category.is_empty() {
            groups.entry(category).or_default().push(entry);
        }
    }

    let mut edges: Vec<PressureEdge> = Vec::new();
    let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
    let empty: Vec<&JsonEntry> = Vec::new();

Les arêtes n’existent qu’entre les paires de catégories que l’ontologie déclare opposées : l’implémentation groupe donc d’abord par catégorie et ne croise que les paires nommées, au lieu d’examiner toutes les paires d’entrées. L’argument de complexité est écrit dans le code, à côté du code qu’il justifie.

Un contrat, deux clients

Un service FastAPI expose le moteur sous un document OpenAPI généré — 49 opérations et 87 schémas — dont sont dérivés, plutôt que maintenus à la main, les types TypeScript employés par le client de bureau. Le client lui-même est en Tauri et React, avec une suite de bout en bout qui pilote l’application réelle. Un point d’entrée en ligne de commande couvre les mêmes opérations, et un test de parité tient les deux points d’entrée à la même surface.