Chronologie du contrat : spine relationnelle assemblée en dbt (situation + grille FACTURATION descendues)
Statut
accepted — révise ADR-0039 (qui gardait l'attribution de situation au cœur) en prolongeant son intention : la Chronologie du contrat devient un substrat relationnel assemblé en dbt. Aligne sur ADR-0029 / ADR-0020 (l'assemblage relationnel vit en dbt, le cœur arbitre). Préserve ADR-0023 (périodisations séparées) et la pureté d'horizon d'ADR-0019 / #179.
Contexte
À l'ère « tout-dataframe », la Chronologie des relevés (ligne de temps énergie) était ré-assemblée à l'intérieur de pipeline_energie (_assembler_chronologie), puis une seconde fois par _composer pour produire releves_utilises (la trace d'index). Deux sous-graphes LazyFrame indépendants, collectés séparément → même assemblage exécuté deux fois, et surtout : « les relevés tracés = les relevés que l'énergie a utilisés » ne tenait que par convention (mêmes arguments aux deux sites), pas par construction. Étendre ce schéma à la part fixe et à la timeline consultable aurait généré les événements FACTURATION en trois endroits — la même classe de duplication.
Le réflexe « fusionner une bonne fois en une timeline » bute sur la donnée réelle. Largeur effective des sources de faits (schémas Pandera actuels) : événements C15 ≈ 49 colonnes, relevés ≈ 19, jalons d'affaire ≈ 11. Une frame large unique (Single-Table Inheritance) ≈ 66 colonnes aujourd'hui, ~80 avec d'autres sources, chaque ligne 70–85 % nulle (une ligne événement est nulle sur tous les index_*, une ligne relevé est nulle sur tous les attributs d'événement). Symptôme textbook de STI quand les sous-types partagent peu (ici ~10 colonnes d'épine sur 66).
Or on est dans un store relationnel : flux_c15, le mart canonique releves (ADR-0029), flux_affaires sont déjà des relations en DuckDB. Le problème n'est pas une forme de frame, c'est une modélisation relationnelle. Et l'argument d'ADR-0039 pour garder l'attribution de situation au cœur (« un forward-fill en dbt duplique l'historique que le cœur dérive déjà ») était contingent de l'approche dataframe : il ne tient que si cœur et dbt forward-fillent. Si dbt en devient le propriétaire unique et que le cœur cesse de re-dériver, c'est un déménagement vers l'étage d'assemblage (ADR-0029/0020), pas une duplication ; et un forward-fill sur la timeline d'événements complète (MDPRM compris) n'est plus le « collage sur des lectures » qu'ADR-0039 corrigeait — c'est le substrat lui-même.
Décision
La Chronologie du contrat est une spine relationnelle assemblée en dbt — Class-Table Inheritance :
-
Spine = une épine commune
(pdl, ref_situation_contractuelle, date, source, type_fait)+ les attributs de situation (niveau_ouverture_services,formule_tarifaire_acheminement, puissance, segment…) forward-fillés en SQL sur la timeline d'événements complète :last_value(col IGNORE NULLS) OVER (PARTITION BY ref_situation_contractuelle ORDER BY date). Une ligne par fait, mince (~10 colonnes), ordonnable et filtrable par RSC ou PDL (les deux grains de filtre d'un même substrat, ADR-0039). -
Relations = les marts par source, inchangés (événements C15, relevés canoniques, jalons), clés sur l'identité de la spine. Pas de frame large, pas de payload imbriqué.
-
La grille FACTURATION descend dans la spine. Les bornes calendaires (1ᵉʳ de chaque mois, de l'entrée à
min(résiliation, borne_généreuse)par RSC) sont générées en dbt — opération purement calendaire (generate_series(... interval '1 month')), pas horizon-paramétrique. Étant des lignes de la spine, elles héritent la situation gratuitement via le même forward-fill SQL (fin du lookup as-of séparé). -
Le cœur cesse de dériver la situation et de générer les FACTURATION. Il ne garde que (a) le filtre horizon (
date_evenement <= horizon, défaut #179 résolu au boundary) — l'horizon devient un filtre sur une grille pré-générée, plus un input de génération, ce qui préserve la pureté ; (b) la détection de ruptures par branche (impacte_abonnement/energie,resume_modification). -
Chaque branche =
filtre(spine) ⨝ sa relation+ calcule + bundle ce qu'elle a utilisé. La duplication de chronologie disparaît par construction : énergie etreleves_utilisessélectionnent la même spine → mêmes données garanties, sans recalcul logique convergent à maintenir. -
Extensibilité — prévue explicitement. Une nouvelle source de faits entre comme une relation rattachée à la spine, sans élargir l'existant ni toucher les branches qui l'ignorent : les affaires (
type_fait = jalon, rattachées parid_affaire— déjà l'Id_Affairedes événements C15) en sont la prochaine. R15 n'est pas une nouvelle relation : c'est une source de relevés supplémentaire, qui enrichit la relationrelevesdéjà multi-sources (SOURCES_CHRONOLOGIEla prévoit). -
Grain : jour pour l'appariement, timestamp pour l'ordre. L'appariement relevé↔borne FACTURATION est un equi-join
(rsc, date)au grain jour — l'asof« nearest 4h » (TOLERANCE_APPARIEMENT_RELEVES) de_assembler_chronologieet son portage SQL délicat disparaissent. L'ordre du forward-fill de situation reste porté par le timestampdate_evenement(… OVER (PARTITION BY rsc ORDER BY date_evenement)). Validé par un spike prod (20/06/2026) : sur les 14(RSC, jour)multi-événements, 0 timestamp dupliqué → le timestamp est un ordre total correct ; les 7 collisions deniveau_ouverture_servicessont toutesCMAT (niveau=0, tôt)+MDPRM (niveau=1, plus tard)(le MDPRM, postérieur, gagne → bon niveau ; cf. ADR-0039), + 1 sur la puissance (MDPRM 6 → MCT 9). Garde-fou : un test dbt asserte 0 collision(rsc, date_evenement, attribut)— si un jour deux événements changent le même attribut au même instant, il échoue plutôt que de trancher au hasard.
Alternatives écartées
- Frame large unique (STI) — rejetée : ~66 colonnes 70–85 % nulles aujourd'hui, ballonnement à chaque source ; les branches doivent connaître les colonnes des sources qu'elles ignorent.
- Spine + payload imbriqué (struct), en Polars — rejetée : extensible mais impose un
unnestà chaque branche, et surtout n'est pas l'idiome natif du store relationnel (ADR-0029/0020 disent : assembler en dbt). - Garder la génération FACTURATION au cœur (carve-out initialement proposé) — rejetée : régénérée pour l'énergie, l'abonnement et la timeline consultable = trois sites, la duplication même qu'on supprime. La génération est calendaire (relationnelle), pas horizon-paramétrique.
- Garder l'attribution de situation au cœur (statu quo ADR-0039) — rejetée maintenant que c'est un déménagement, pas une duplication : dbt devient propriétaire unique, le cœur consomme.
Conséquences
- dbt : nouveau mart spine (union événements ∪ relevés ∪ grille FACTURATION, situation forward-fillée en SQL en une passe) ; les marts relations restent.
pipeline_historiquerétréci : perd le forward-fill de situation (expr_colonnes_a_propager) et la génération FACTURATION (inserer_evenements_facturation) ; garde la détection de ruptures et le filtre horizon. Candidat à se dissoudre en projection de branche.- Contrats (ADR-0019, Contrat de pipeline) : un schéma Pandera pour la spine et un par relation ; chaque pipeline déclare son contrat d'entrée. Un petit loader relationnel (ADR-0019, « lit une table →
LazyFrame[Schema core], aucune logique métier ») fait l'appelspine ⨝ relationet sort la LF. - Fork du join — résolu (grain jour). L'appariement étant un equi-join
(rsc, date)(plus d'asof, décision §7), la Chronologie des relevés s'assemble entièrement en dbt (spine + equi-join + dédoublonnage prioritéQUALIFY row_number()), exposée en un mart ; le loader est un read fin ; l'énergie ne garde que son découpage (shift/diff/qualité, ADR-0023). Une seule projection relevés (partagée énergie + NC), pas un mart par branche. Le timestampdate_evenementreste une colonne de la spine (ordre du forward-fill), pas une clé d'appariement. - Garde-fou de prémisse : test dbt
0 = count((rsc, date_evenement, attribut) en collision)pour chaque attribut de situation — encode l'invariant « le timestamp ordonne sans ambiguïté » vérifié au spike, et le surveille à chaque ingestion future. - Déterminisme : la grille dbt est générée jusqu'à une borne ≥ tout horizon interrogeable ; le filtre horizon du cœur donne le résultat exact, identique au rejeu (#179 préservé).
- Correction incidente d'un bug latent de bord de mois (découvert au behaviour-diff #375 sur données réelles).
pipeline_historiquegénère la grille FACTURATION viadt.month_start()de Polars, qui préserve l'heure : pour une entrée le mois précédant l'horizon (typiquement à00:01),premier_mois = 1ᵉʳ du mois 00:01dépassedernier_mois = 1ᵉʳ du mois 00:00(l'horizon, à minuit) → la borne FACTURATION unique de ces contrats est silencieusement omise (période pourtant facturable ; incohérent avec les PDL plus anciens, qui la reçoivent). La spine génère la grille en calendaire pur (date_trunc, heure remise à zéro) → la borne est présente. C'est donc le seul écart de comportement vspipeline_historique, et c'est un fix : la spine est un sur-ensemble strict de l'historique (rien perdu). L'écart est horizon-dépendant (la borne omise dépend de l'horizon, à minuit) donc irréductible sous l'architecture « horizon = filtre » — le répliquer exigerait de réinjecter l'horizon dans la génération, ce que cette ADR abolit. Suivi en issue #380 (impact facturation à confirmer) ; devient effectif quand les consommateurs basculent sur la spine (S5, #378). - Glossaire :
core/CONTEXT.md— Chronologie du contrat (spine relationnelle) et Horizon de facturation (filtre, plus génération) affûtés. - Issues à créer (
/to-issues) : mart spine dbt + forward-fill SQL + grille FACTURATION ; rétrécissementpipeline_historique; migration branches énergie et abonnement sur la spine ; loader relationnel + contrats ; point d'extension affaires (type_fait = jalon).