diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/.gitignore b/05_deliverables_mvp/faisabilite/v18_master_intake/.gitignore new file mode 100644 index 0000000..68f1134 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/.gitignore @@ -0,0 +1,5 @@ +# Artefacts de génération locale (jamais commités — produits à la demande). +__pycache__/ +*.pyc +build/ +out/ diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/README.md b/05_deliverables_mvp/faisabilite/v18_master_intake/README.md new file mode 100644 index 0000000..affe986 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/README.md @@ -0,0 +1,87 @@ +# V18 · Master Project Intake / Master Data Model (Phase 1) + +**Moteur :** V18 · Phase 1/15 (première du plan « ORDRE DÉVELOPPEMENT » de la +[directive V18](../../../DIRECTIVE_V18_MASTER_FEASIBILITY_ENGINE_20260810.md)). +**Débloqué par :** [V18_GO_PHASE_1_20260812.md](../../../V18_GO_PHASE_1_20260812.md) +(D-06 APPROVED) + [V18_DECISIONS_D07_D08_20260812.md](../../../V18_DECISIONS_D07_D08_20260812.md) +(D-08 : sur-ensemble STRICT `brief.json` V12). +**Fondé sur :** [OTO V18 · Migration & Architecture Audit](../../../OTO_V18_MIGRATION_ARCHITECTURE_AUDIT_20260810.md) +§4 (data model) · §5 (provenance) · §11 (séquence). +**Statut :** livrable **prêt pour CP0** (Document Completeness) · en attente de la +validation Michel AVANT toute Phase 2 (séquence GO_PHASE_1 §Validation à chaque +étape). **Auto-score 4Big cible : ≥ 95/100** (doc + contrat schéma + couverture +de tests + CLI reproductible), aligné CLAUDE.md #5. + +## Ce que livre la Phase 1 (et ce qu'elle NE livre PAS) + +Phase 1 = **le data model, pas un moteur métier.** Conformément à l'audit §11 +(« Zéro moteur métier tant que le data model n'est pas validé »), ce module ne +calcule AUCUNE faisabilité, AUCUN DCF/IRR/DSCR (ceux-là arrivent aux moteurs 4/8, +formules Big4 débloquées D-07), n'écrit rien sur le VPS (#8) et n'invente aucune +donnée (#6). + +Il fournit le **contrat** du Master Dataset — « One Master Dataset · Multiple +Outputs » : + +| Brique | Fichier | Rôle | +|---|---|---| +| Schéma maître | [`master_intake.schema.json`](master_intake.schema.json) | Sur-ensemble strict de [`brief.schema.json`](../generator/brief.schema.json) V12, inclus via `allOf`/`$ref` ; ajoute `dataset_version`, les registres et les défs `data_status`/`data_category`/`data_point`. | +| Modèle de données | [`mdmlib/master_data_model.py`](mdmlib/master_data_model.py) | Les **11 statuts** + **5 catégories** + `DataPoint` traçable + garde des canoniques #9/#10. | +| Evidence Register | [`mdmlib/evidence_register.py`](mdmlib/evidence_register.py) | Registre documentaire (id · version · date) + liens donnée→pièce + détection de provenance pendouillante. | +| CLI | [`master_intake_gen.py`](master_intake_gen.py) | `validate` (contrat + intégrité) et `maturity` (complétude honnête). | +| Fixture pilote | [`fixtures/intake_P01_coralis.json`](fixtures/intake_P01_coralis.json) | P01 Coralis · majoritairement `PENDING` (aucun chiffre inventé). | +| Tests | [`tests/test_master_intake.py`](tests/test_master_intake.py) | 32 tests · contrat, rétro-compat V12, garde canonique, intégrité preuves. | + +## Les 11 statuts de donnée (ordre canonique) + +`VERIFIED` · `CONFIRMED` · `SOURCE_BASED` · `CALCULATED` · `ESTIMATED` · +`ASSUMPTION` · `TARGET` · `BANK_REQUIREMENT` · `PENDING` · `MISSING` · +`NOT_APPLICABLE` + +Une donnée n'est jamais un chiffre nu : c'est un `DataPoint` portant sa +provenance (`source_document` · `source_date` · `status` · `confidence` · +`validated_by`). C'est la généralisation du patron `{formule + opérandes +sourcés/null}` déjà éprouvé dans `banclib/finance.py` (audit §5). + +## Les 5 catégories de séparation + +`INPUT` · `TARGET` · `ASSUMPTION` · `BANK_REQUIREMENT` · `CALCULATED` — le RÔLE de +la donnée dans le modèle (distinct du statut, qui dit sa fiabilité). + +## Rétro-compatibilité V12 (D-08) + +- Toute clé du `brief.json` V12 conserve son nom exact (le parser Publiciste + continue de lire les données V18, ignorant silencieusement les champs V18). +- `brief.schema.json` V12 est **inclus** (jamais recopié) via `allOf`/`$ref` sur + son `$id` canonique → source unique, zéro duplication. +- Les champs V18 sont ajoutés en **extension**, jamais en remplacement (test + `test_no_v12_key_shadowed_with_conflicting_type`). +- `dataset_version` distingue V12 vs V18. + +## Utilisation + +```bash +python3 master_intake_gen.py validate fixtures/intake_P01_coralis.json +python3 master_intake_gen.py maturity fixtures/intake_P01_coralis.json +python3 -m unittest discover -s tests -v +``` + +Les lacunes `PENDING`/`MISSING` **ne sont pas des erreurs** : elles mesurent la +complétude (audit §4 · « la fraction de champs absents explosera au démarrage… +ce n'est pas un bug »). `validate` ne sort en erreur que sur une **violation de +contrat** (statut/catégorie inconnu, canonique saisi, preuve pendouillante). + +## Anti-invention & angle mort déclaré (#6 · #8) + +Les **A1-A20 fins** vivent dans la directive complète root-owned (57 chapitres), +illisible par le worker (audit §0). Ce module livre donc le **cadre** ouvert +(`intake_sections` clés `A1..A20`, contenu à confronter au CP0) — il ne fabrique +aucun champ A1-A20. Toute divergence avec la spec fine de Michel sera résolue à +la validation Phase 1. + +## Prochain incrément (post-CP0) + +Après validation Michel du data model (CP0), le module sera câblé au gate CI +(`.gitea/workflows/ci.yml` + régénération des méta-artefacts régression/4Big) et +Phase 2 (Document / Evidence Engine · généralisation `champs_manquants` → gate +CP0 automatisé) pourra démarrer. diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/fixtures/intake_P01_coralis.json b/05_deliverables_mvp/faisabilite/v18_master_intake/fixtures/intake_P01_coralis.json new file mode 100644 index 0000000..ea2756e --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/fixtures/intake_P01_coralis.json @@ -0,0 +1,68 @@ +{ + "projet": "P01", + "nom": "Coralis", + "synthetique": true, + "dataset_version": "V18", + "sources": [ + "CLAUDE.md §Projets (code projet canonique)", + "V18_GO_PHASE_1_20260812.md §Projet pilote (P01 Coralis)" + ], + "masterplan": { + "terrain_m2": null, + "nb_unites": null, + "nb_phases": null + }, + "intake_sections": { + "A1": { "titre": "Identification projet", "statut": "amorce" } + }, + "document_register": [ + { + "doc_id": "DOC-CLAUDE-MD", + "title": "CLAUDE.md — constitution du mandat (§Projets)", + "filename": "CLAUDE.md", + "version": "2026-08-05", + "source_date": "2026-08-05" + } + ], + "data_register": [ + { + "key": "projet.code", + "value": "P01", + "category": "INPUT", + "status": "SOURCE_BASED", + "source_document": "DOC-CLAUDE-MD", + "source_date": "2026-08-05", + "confidence": 1.0, + "validated_by": "Michel Roy", + "unit": null, + "note": "Code projet canonique ancré CLAUDE.md §Projets." + }, + { + "key": "programme.nb_unites", + "value": null, + "category": "INPUT", + "status": "PENDING", + "source_document": null, + "source_date": null, + "confidence": null, + "validated_by": null, + "unit": "unités", + "note": "En attente du brief maître P01 (CP0 Document Completeness)." + }, + { + "key": "financier.dscr_cible", + "value": null, + "category": "BANK_REQUIREMENT", + "status": "PENDING", + "source_document": null, + "source_date": null, + "confidence": null, + "validated_by": null, + "unit": "ratio", + "note": "Exigence bancaire DSCR — formules Big4 débloquées D-07 ; valeur à confirmer par la banque (moteur 8 · D-02)." + } + ], + "evidence_register": [ + { "data_key": "projet.code", "doc_ids": ["DOC-CLAUDE-MD"] } + ] +} diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/master_intake.schema.json b/05_deliverables_mvp/faisabilite/v18_master_intake/master_intake.schema.json new file mode 100644 index 0000000..74402fe --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/master_intake.schema.json @@ -0,0 +1,112 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto.dtp/schemas/faisabilite/master_intake.schema.json", + "title": "OTO V18 · Master Project Intake (A1-A20) · Master Data Model", + "description": "Formulaire d'intake maître V18 qui remplace le Formulaire Briefing V12. D-08 (V18_DECISIONS_D07_D08_20260812.md) : SUR-ENSEMBLE STRICT de brief.json V12 — le contrat V12 brief.schema.json est INCLUS tel quel via allOf/$ref (toute clé V12 conserve son nom exact, rétro-compat parser Publiciste), et les champs V18 sont ajoutés en EXTENSION, jamais en remplacement. Le champ dataset_version distingue V12 vs V18. Anti-invention #6 : les A1-A20 fins restent ouverts tant que la spec root-owned de Michel (#8) n'est pas confrontée — ce schéma livre le CADRE (statuts · catégories · registres), pas des champs A1-A20 fabriqués.", + "type": "object", + "allOf": [ + { "$ref": "https://oto.dtp/schemas/faisabilite/brief.schema.json" } + ], + "required": ["projet", "dataset_version"], + "properties": { + "dataset_version": { + "type": "string", + "enum": ["V12", "V18"], + "description": "Distingue un dataset legacy V12 d'un intake V18 (D-08 règle 6). Un intake maître V18 porte « V18 »." + }, + "intake_sections": { + "type": "object", + "description": "Slots A1-A20 du Master Project Intake (directive V18 §Master Project Intake). Contenu FIN volontairement OUVERT (additionalProperties) : les 20 sections détaillées vivent dans la directive complète root-owned (#8, audit §0) et seront confrontées par Michel au CP0 — les fabriquer ici serait une invention (#6). Chaque clé est un id de section A1..A20.", + "propertyNames": { "pattern": "^A([1-9]|1[0-9]|20)$" }, + "additionalProperties": true + }, + "document_register": { + "type": "array", + "description": "Catalogue des pièces probantes (Evidence Room · audit §18). Register documents / files / versions (V18_GO_PHASE_1).", + "items": { "$ref": "#/definitions/document_ref" } + }, + "data_register": { + "type": "array", + "description": "Le Master Dataset : chaque donnée AVEC sa provenance (statut · source · confiance · validateur). One Master Dataset · Multiple Outputs — les 3 sorties (Bank/Full/Ops) sont des projections de ce registre.", + "items": { "$ref": "#/definitions/data_point" } + }, + "evidence_register": { + "type": "array", + "description": "Liens donnée→pièce : rattache un data_key à un ou plusieurs doc_id du document_register (intégrité référentielle : pas de provenance fantôme).", + "items": { + "type": "object", + "required": ["data_key", "doc_ids"], + "additionalProperties": false, + "properties": { + "data_key": { "type": "string" }, + "doc_ids": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + } + } + } + }, + "definitions": { + "data_status": { + "description": "Les 11 statuts obligatoires (V18_GO_PHASE_1 §Data status). Ordre canonique = du plus fiable au sans-objet ; miroir de mdmlib.master_data_model.STATUSES.", + "type": "string", + "enum": [ + "VERIFIED", + "CONFIRMED", + "SOURCE_BASED", + "CALCULATED", + "ESTIMATED", + "ASSUMPTION", + "TARGET", + "BANK_REQUIREMENT", + "PENDING", + "MISSING", + "NOT_APPLICABLE" + ] + }, + "data_category": { + "description": "Les 5 catégories de séparation (V18_GO_PHASE_1 §Séparation). Le RÔLE de la donnée dans le modèle ; miroir de mdmlib.master_data_model.CATEGORIES.", + "type": "string", + "enum": [ + "INPUT", + "TARGET", + "ASSUMPTION", + "BANK_REQUIREMENT", + "CALCULATED" + ] + }, + "data_point": { + "type": "object", + "description": "Une donnée du Master Dataset avec sa provenance intégrale.", + "required": ["key", "category", "status"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "minLength": 1 }, + "value": {}, + "category": { "$ref": "#/definitions/data_category" }, + "status": { "$ref": "#/definitions/data_status" }, + "source_document": { "type": ["string", "null"] }, + "source_date": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 1 }, + "validated_by": { "type": ["string", "null"] }, + "unit": { "type": ["string", "null"] }, + "note": { "type": ["string", "null"] } + } + }, + "document_ref": { + "type": "object", + "description": "Une pièce probante cataloguée (id unique · version · date).", + "required": ["doc_id", "title"], + "additionalProperties": false, + "properties": { + "doc_id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "filename": { "type": ["string", "null"] }, + "version": { "type": ["string", "null"] }, + "source_date": { "type": ["string", "null"] } + } + } + } +} diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/master_intake_gen.py b/05_deliverables_mvp/faisabilite/v18_master_intake/master_intake_gen.py new file mode 100644 index 0000000..500d2e3 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/master_intake_gen.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""master_intake_gen · CLI Phase 1 du Master Project Intake V18. + +PÉRIMÈTRE STRICT Phase 1 (audit §11 · « Zéro moteur métier tant que le data +model n'est pas validé ») : ce CLI NE GÉNÈRE aucune faisabilité, aucun chiffre +financier, aucun rendu. Il VALIDE un intake maître contre le contrat V18 +(statuts · catégories · provenance · intégrité référentielle des preuves) et +imprime une MATURITÉ DATA honnête (présents vs lacunes) — la mesure de +complétude qui alimentera le CP0 (Document Completeness) au moteur 2. + +Usage : + python3 master_intake_gen.py validate + python3 master_intake_gen.py maturity + +Sortie : code 0 si le contrat est respecté (les lacunes PENDING/MISSING ne sont +PAS des erreurs — c'est la complétude, pas un échec) ; code 1 si le contrat est +violé (statut/catégorie inconnu · preuve pendouillante · JSON mal formé) ; +code 2 si l'usage est incorrect. + +stdlib pur. L'oracle `jsonschema` est utilisé s'il est présent (avec +brief.schema.json V12 dans le store de résolution du $ref) ; sinon la validation +structurelle du contrat reste assurée par mdmlib (en mémoire). +""" + +from __future__ import annotations + +import json +import os +import sys +from collections import Counter +from typing import Any + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +from mdmlib import ( # noqa: E402 + CATEGORIES, + STATUSES, + DataPoint, + Document, + DocumentRegister, + EvidenceRegister, + MasterDataError, +) + +SCHEMA_PATH = os.path.join(_HERE, "master_intake.schema.json") +BRIEF_SCHEMA_PATH = os.path.join(_HERE, "..", "generator", "brief.schema.json") + + +def _load_json(path: str) -> Any: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def build_registers(intake: dict) -> tuple[list[DataPoint], EvidenceRegister]: + """Reconstruit le Master Dataset en mémoire — lève MasterDataError si le + contrat est violé (statut/catégorie/canonique/provenance fantôme).""" + docs = DocumentRegister() + for d in intake.get("document_register", []) or []: + docs.add( + Document( + doc_id=d.get("doc_id", ""), + title=d.get("title", ""), + filename=d.get("filename"), + version=d.get("version"), + source_date=d.get("source_date"), + ) + ) + + points: list[DataPoint] = [] + for dp in intake.get("data_register", []) or []: + points.append( + DataPoint( + key=dp.get("key", ""), + value=dp.get("value"), + category=dp.get("category", ""), + status=dp.get("status", ""), + source_document=dp.get("source_document"), + source_date=dp.get("source_date"), + confidence=dp.get("confidence"), + validated_by=dp.get("validated_by"), + unit=dp.get("unit"), + note=dp.get("note"), + ) + ) + + ev = EvidenceRegister(docs) + for link in intake.get("evidence_register", []) or []: + for doc_id in link.get("doc_ids", []) or []: + ev.link(link.get("data_key", ""), doc_id) + return points, ev + + +def _schema_validate(intake: dict) -> list[str]: + """Validation structurelle via l'oracle jsonschema (best-effort). + + Renvoie [] si valide OU si l'oracle est absent (skip silencieux, comme les + 17 skips « par design » de la matrice régression sous `python -S`).""" + try: + import jsonschema # type: ignore + except Exception: + return [] + schema = _load_json(SCHEMA_PATH) + brief = _load_json(BRIEF_SCHEMA_PATH) + store = { + schema.get("$id", ""): schema, + brief.get("$id", ""): brief, + } + resolver = jsonschema.RefResolver.from_schema(schema, store=store) + validator = jsonschema.Draft7Validator(schema, resolver=resolver) + return [e.message for e in validator.iter_errors(intake)] + + +def cmd_validate(path: str) -> int: + intake = _load_json(path) + schema_errors = _schema_validate(intake) + try: + points, ev = build_registers(intake) + except MasterDataError as exc: + print(f"❌ Contrat Master Data Model violé : {exc}") + return 1 + dangling = ev.dangling_evidence(points) + + print(f"Intake : {path}") + print(f" dataset_version : {intake.get('dataset_version', '(absent)')}") + print(f" projet : {intake.get('projet', '(absent)')}") + print(f" pièces : {len(ev.documents)}") + print(f" données : {len(points)}") + if schema_errors: + print("❌ Non conforme au schéma master_intake.schema.json :") + for m in schema_errors: + print(f" · {m}") + return 1 + if dangling: + print("❌ Preuve(s) pendouillante(s) (SOURCE_BASED sans pièce résolue) :") + for k in dangling: + print(f" · {k}") + return 1 + print("✅ Contrat V18 respecté (statuts · catégories · provenance intègres).") + return 0 + + +def cmd_maturity(path: str) -> int: + intake = _load_json(path) + try: + points, _ = build_registers(intake) + except MasterDataError as exc: + print(f"❌ Contrat Master Data Model violé : {exc}") + return 1 + total = len(points) + present = sum(1 for p in points if p.present) + by_status: Counter[str] = Counter(p.status for p in points) + by_cat: Counter[str] = Counter(p.category for p in points) + + pct = (100.0 * present / total) if total else 0.0 + print(f"Maturité data · {path}") + print(f" données présentes : {present}/{total} ({pct:.1f} %)") + print(" par statut :") + for s in STATUSES: + if by_status.get(s): + print(f" {s:<16} {by_status[s]}") + print(" par catégorie :") + for c in CATEGORIES: + if by_cat.get(c): + print(f" {c:<16} {by_cat[c]}") + print(" (les lacunes PENDING/MISSING sont attendues au démarrage · audit §4)") + return 0 + + +def main(argv: list[str]) -> int: + if len(argv) < 2 or argv[1] in ("-h", "--help"): + print(__doc__) + return 0 if (len(argv) >= 2 and argv[1] in ("-h", "--help")) else 2 + cmd = argv[1] + if cmd in ("validate", "maturity"): + if len(argv) != 3: + print(f"usage : master_intake_gen.py {cmd} ", file=sys.stderr) + return 2 + return (cmd_validate if cmd == "validate" else cmd_maturity)(argv[2]) + print(f"commande inconnue « {cmd} » (attendu : validate | maturity)", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/__init__.py b/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/__init__.py new file mode 100644 index 0000000..1277d06 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/__init__.py @@ -0,0 +1,40 @@ +"""Master Data Model V18 — bibliothèque (Phase 1 · Master Project Intake). + +Débloquée par `V18_GO_PHASE_1_20260812.md` (D-06 APPROVED) + +`V18_DECISIONS_D07_D08_20260812.md` (D-08). Voir `../README.md`. + +Expose le contrat en mémoire du Master Dataset (One Master Dataset · Multiple +Outputs) : statuts, catégories, DataPoint traçable, registres documentaires. +Aucun moteur métier ici (Zéro moteur tant que le data model n'est pas validé au +CP0 · audit §11). +""" + +from .master_data_model import ( + CANONICAL, + CATEGORIES, + STATUSES, + DataPoint, + MasterDataError, + assert_not_canonical, + is_filled, + is_present, +) +from .evidence_register import ( + Document, + DocumentRegister, + EvidenceRegister, +) + +__all__ = [ + "CANONICAL", + "CATEGORIES", + "STATUSES", + "DataPoint", + "MasterDataError", + "assert_not_canonical", + "is_filled", + "is_present", + "Document", + "DocumentRegister", + "EvidenceRegister", +] diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/evidence_register.py b/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/evidence_register.py new file mode 100644 index 0000000..8f56cbf --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/evidence_register.py @@ -0,0 +1,118 @@ +"""Evidence Register V18 · registre documentaire + traçabilité des preuves. + +Phase 1 · brique « Data Register + Evidence Register (traçabilité) » de +`V18_GO_PHASE_1_20260812.md` : « Register documents / files / versions ». C'est +l'amorce en mémoire du futur moteur 2 (Document / Evidence Engine · audit §11) — +ici on livre le REGISTRE et ses invariants d'intégrité, pas encore l'extraction. + +Deux registres complémentaires : + + * DocumentRegister · catalogue des pièces probantes (id · titre · fichier · + version · date). Une pièce = une source citable, versionnée (audit §4.3 : + baseline vs actual = vues horodatées, jamais un écrasement). + * EvidenceRegister · lie chaque `DataPoint.key` à la (aux) pièce(s) qui la + justifie(nt). Ferme la « provenance fantôme » : un DataPoint SOURCE_BASED + dont le `source_document` ne résout à AUCUNE pièce enregistrée est un lien + pendouillant → détecté ici (`dangling_evidence`). + +Anti-invention (#6) : le registre n'invente aucune pièce ; il ne fait que +cataloguer ce qui est déclaré et VÉRIFIER la cohérence référentielle. stdlib pur, +aucune écriture VPS (#8). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable, Optional + +from .master_data_model import DataPoint, MasterDataError + + +@dataclass(frozen=True) +class Document: + """Une pièce probante cataloguée (audit §18 · Evidence Room).""" + + doc_id: str + title: str + filename: Optional[str] = None + version: Optional[str] = None + source_date: Optional[str] = None + + def __post_init__(self) -> None: + if not self.doc_id or not str(self.doc_id).strip(): + raise MasterDataError("Document.doc_id ne peut être vide.") + if not self.title or not str(self.title).strip(): + raise MasterDataError("Document.title ne peut être vide.") + + +class DocumentRegister: + """Catalogue des pièces probantes, indexé par `doc_id` unique.""" + + def __init__(self) -> None: + self._docs: dict[str, Document] = {} + + def add(self, doc: Document) -> Document: + if doc.doc_id in self._docs: + raise MasterDataError( + f"doc_id dupliqué « {doc.doc_id} » — l'id de pièce doit être " + f"unique (registre = source citable non ambiguë)." + ) + self._docs[doc.doc_id] = doc + return doc + + def get(self, doc_id: str) -> Optional[Document]: + return self._docs.get(doc_id) + + def has(self, doc_id: str) -> bool: + return doc_id in self._docs + + def __len__(self) -> int: + return len(self._docs) + + def ids(self) -> list[str]: + return sorted(self._docs) + + +class EvidenceRegister: + """Lie les DataPoint à leurs pièces + vérifie l'intégrité référentielle.""" + + def __init__(self, documents: Optional[DocumentRegister] = None) -> None: + self.documents: DocumentRegister = documents or DocumentRegister() + # key du DataPoint -> ensemble ordonné de doc_id justificatifs + self._links: dict[str, list[str]] = {} + + def link(self, data_key: str, doc_id: str) -> None: + """Rattache la donnée `data_key` à la pièce `doc_id` (déjà cataloguée).""" + if not self.documents.has(doc_id): + raise MasterDataError( + f"lien vers une pièce inconnue « {doc_id} » — la cataloguer " + f"d'abord dans le DocumentRegister (pas de preuve fantôme)." + ) + bucket = self._links.setdefault(data_key, []) + if doc_id not in bucket: + bucket.append(doc_id) + + def evidence_for(self, data_key: str) -> list[str]: + """Pièces justificatives (doc_id) déclarées pour cette donnée.""" + return list(self._links.get(data_key, ())) + + def dangling_evidence(self, data_points: Iterable[DataPoint]) -> list[str]: + """Clés de DataPoint SOURCE_BASED dont la pièce citée n'est PAS cataloguée. + + C'est le cœur de l'intégrité : un DataPoint qui prétend venir d'un + document (`status == SOURCE_BASED`) doit pouvoir résoudre ce document — + soit via un lien Evidence, soit via un `source_document` == doc_id connu. + Toute clé retournée est une provenance non résoluble (à corriger). + """ + missing: list[str] = [] + for dp in data_points: + if dp.status != "SOURCE_BASED": + continue + linked = self.evidence_for(dp.key) + resolves = any(self.documents.has(d) for d in linked) or ( + dp.source_document is not None + and self.documents.has(dp.source_document) + ) + if not resolves: + missing.append(dp.key) + return sorted(missing) diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/master_data_model.py b/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/master_data_model.py new file mode 100644 index 0000000..694216f --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/mdmlib/master_data_model.py @@ -0,0 +1,222 @@ +"""Master Data Model V18 · statuts de donnée + catégories + DataPoint. + +Phase 1 du moteur V18 (`DIRECTIVE_V18_MASTER_FEASIBILITY_ENGINE_20260810.md` +« ORDRE DÉVELOPPEMENT · 1. Master Project Intake / Data Model »), débloquée par +`V18_GO_PHASE_1_20260812.md` (D-06 APPROVED) et `V18_DECISIONS_D07_D08_20260812.md` +(D-08 : Master Intake A1-A20 = sur-ensemble STRICT de `brief.json` V12). + +Principe « One Master Dataset · Multiple Outputs » : une donnée n'est jamais un +chiffre nu. C'est un `DataPoint` qui porte SA PROVENANCE — statut de fiabilité, +document source, date, confiance, validateur. Les 3 sorties V18 (Bank Package / +Full Feasibility / Ops) sont des PROJECTIONS de ce dataset, jamais des copies +éditables (audit §4/§7). + +Anti-invention (CLAUDE.md #6 · directive V18 « NE PAS inventer données ») : le +modèle n'attribue AUCUNE valeur ni statut par défaut « optimiste ». Une donnée +absente est explicitement `MISSING`/`PENDING` — la complétude devient une mesure +honnête (audit §4, « la fraction de champs absents explosera au démarrage… ce +n'est pas un bug »). + +Ce module ne FAIT rien d'externe : ni I/O réseau, ni écriture VPS (#8). stdlib +pur. Il définit le contrat en mémoire ; le schéma machine miroir est +`../master_intake.schema.json` (défs `data_status` / `data_category` / +`data_point`), tenu identique par la suite de tests. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +# --------------------------------------------------------------------------- # +# Les 11 STATUTS de donnée (V18_GO_PHASE_1 §« Data status obligatoire »). +# ORDRE CANONIQUE = du plus fiable (prouvé/vérifié) au moins engageant +# (non applicable). Cet ordre est un CONTRAT : le schéma `data_status.enum` et la +# suite de tests le rejouent à l'identique (aucune ré-ordonnance silencieuse). +# +# VERIFIED · vérifié contre une pièce probante (audit/tiers) +# CONFIRMED · confirmé par une partie prenante autorisée +# SOURCE_BASED · repris verbatim d'un document source daté +# CALCULATED · dérivé par formule traçable d'opérandes sourcés +# ESTIMATED · estimation méthodologique déclarée (non un fait) +# ASSUMPTION · hypothèse de travail explicite (à valider) +# TARGET · objectif fixé (cible), pas une donnée constatée +# BANK_REQUIREMENT · exigence imposée par la banque / le prêteur +# PENDING · attendu, en cours de collecte +# MISSING · absent — devrait exister, non fourni +# NOT_APPLICABLE · sans objet pour ce projet +# --------------------------------------------------------------------------- # +STATUSES: tuple[str, ...] = ( + "VERIFIED", + "CONFIRMED", + "SOURCE_BASED", + "CALCULATED", + "ESTIMATED", + "ASSUMPTION", + "TARGET", + "BANK_REQUIREMENT", + "PENDING", + "MISSING", + "NOT_APPLICABLE", +) + +# Statuts qui dénotent une donnée EXPLOITABLE (présente et défendable) vs une +# lacune. Sert au futur gate CP0 (Document Completeness, moteur 2 · audit §6). +_ABSENT_STATUSES: frozenset[str] = frozenset({"PENDING", "MISSING"}) + +# --------------------------------------------------------------------------- # +# Les 5 CATÉGORIES de séparation (V18_GO_PHASE_1 §« Séparation INPUT / TARGET / +# ASSUMPTION / BANK_REQUIREMENT / CALCULATED »). La CATÉGORIE dit le RÔLE de la +# donnée dans le modèle ; le STATUT dit sa FIABILITÉ. (TARGET / ASSUMPTION / +# BANK_REQUIREMENT / CALCULATED existent dans les deux axes à dessein : un même +# libellé peut être un rôle ET un état — ils ne sont pas confondus.) +# --------------------------------------------------------------------------- # +CATEGORIES: tuple[str, ...] = ( + "INPUT", # donnée d'entrée fournie (le brief V12 vit ici) + "TARGET", # objectif projet (ex. absorption cible) + "ASSUMPTION", # hypothèse de modélisation + "BANK_REQUIREMENT", # contrainte bancaire imposée + "CALCULATED", # sortie dérivée du moteur (jamais saisie) +) + +# --------------------------------------------------------------------------- # +# Paramètres CANONIQUES CLAUDE.md #9/#10 — RÉIMPOSÉS à l'identique du générateur +# V12 (`generator/genlib/model.py CANONICAL`), conformément à l'audit §8 non- +# négociable #9 : « À réimposer identiques dans le Master Data Model V18 (ne pas +# les rendre saisissables) ». Ils ne sont JAMAIS des DataPoint d'intake : ils +# sont IMPOSÉS par le moteur pour tous les projets → aucune invention projet par +# projet possible. La suite de tests re-vérifie l'égalité stricte avec la valeur +# du générateur (single source of truth : toute dérive est rouge). +# --------------------------------------------------------------------------- # +CANONICAL: dict[str, str] = { + "frais_edition_pct": "3 %", + "marketing_pct": "8.5 %", + "point_equilibre_pct": "52 %", + "devises": "USD + DOP", + "paiement": "Cardnet", + "format_doc": "Letter US", +} + +# Clés réservées : ne peuvent JAMAIS être une donnée d'intake saisissable. +_CANONICAL_KEYS: frozenset[str] = frozenset(CANONICAL) + + +class MasterDataError(ValueError): + """Violation du contrat Master Data Model (statut/catégorie/canonique).""" + + +def is_present(status: str) -> bool: + """True si le statut dénote une donnée exploitable (ni PENDING ni MISSING).""" + return status in STATUSES and status not in _ABSENT_STATUSES + + +def assert_not_canonical(key: str) -> None: + """Rejette toute tentative de saisir un paramètre canonique comme intake. + + Les 6 constantes #9/#10 sont IMPOSÉES par le moteur, jamais fournies par le + brief → les capter comme DataPoint rouvrirait la porte à l'invention projet + par projet (audit §8 · l'invariant de migration le plus important). + """ + if key in _CANONICAL_KEYS: + raise MasterDataError( + f"« {key} » est un paramètre CANONIQUE imposé (#9/#10) — " + f"interdit comme donnée d'intake saisissable (audit §8)." + ) + + +@dataclass(frozen=True) +class DataPoint: + """Une donnée du Master Dataset AVEC sa provenance (audit §4/§5). + + Le patron « valeur = {donnée + provenance + statut} » généralise le + `{formule + opérandes sourcés/null}` déjà éprouvé dans `banclib/finance.py` + (audit §5 : « exactement le bon patron pour un moteur financier auditable »). + + Champs de traçabilité (V18_GO_PHASE_1 §« Data Register + Evidence Register : + chaque donnée: source_document · source_date · status · confidence · + validated_by »). + """ + + key: str + value: Any + category: str + status: str + source_document: Optional[str] = None + source_date: Optional[str] = None + confidence: Optional[float] = None + validated_by: Optional[str] = None + unit: Optional[str] = None + note: Optional[str] = None + + def __post_init__(self) -> None: + if not self.key or not str(self.key).strip(): + raise MasterDataError("DataPoint.key ne peut être vide.") + assert_not_canonical(self.key) + if self.category not in CATEGORIES: + raise MasterDataError( + f"catégorie inconnue « {self.category} » " + f"(attendu ∈ {CATEGORIES})." + ) + if self.status not in STATUSES: + raise MasterDataError( + f"statut inconnu « {self.status} » (attendu ∈ {STATUSES})." + ) + if self.confidence is not None: + if not isinstance(self.confidence, (int, float)) or isinstance( + self.confidence, bool + ): + raise MasterDataError("confidence doit être un nombre 0..1 ou None.") + if not 0.0 <= float(self.confidence) <= 1.0: + raise MasterDataError("confidence doit être dans [0, 1].") + # Cohérence provenance ⟺ statut : une donnée SOURCE_BASED prétend venir + # d'un document. Sa provenance peut être fournie SOIT en ligne + # (source_document), SOIT par un lien de l'Evidence Register — un + # DataPoint isolé ne connaît pas le registre, donc la complétude de + # provenance est une propriété du DATASET (EvidenceRegister. + # dangling_evidence), pas du point pris seul. On ne la vérifie donc PAS + # ici (sinon on interdirait le chemin « preuve par lien »). + # + # Symétriquement, un trou (PENDING/MISSING) ne peut pas porter une + # valeur non nulle — ce serait une donnée déguisée en lacune. + if self.status in _ABSENT_STATUSES and is_filled(self.value): + raise MasterDataError( + f"« {self.key} » est {self.status} mais porte une valeur — " + f"une lacune ne peut pas contenir de donnée." + ) + + @property + def present(self) -> bool: + """Raccourci : la donnée est-elle exploitable (pas une lacune) ?""" + return is_present(self.status) + + def as_dict(self) -> dict[str, Any]: + """Projection sérialisable (ordre de clés stable pour byte-repro).""" + return { + "key": self.key, + "value": self.value, + "category": self.category, + "status": self.status, + "source_document": self.source_document, + "source_date": self.source_date, + "confidence": self.confidence, + "validated_by": self.validated_by, + "unit": self.unit, + "note": self.note, + } + + +def is_filled(value: Any) -> bool: + """True si `value` est une donnée réelle (pas None ni placeholder vide). + + Aligné sur `generator/genlib/model.py is_filled` : un vrai 0 numérique est + une donnée valide ; None / chaîne vide / liste vide ne le sont pas. + """ + if value is None: + return False + if isinstance(value, bool): + return True + if isinstance(value, (int, float)): + return True + if isinstance(value, (list, tuple, dict)): + return len(value) > 0 + return bool(str(value).strip()) diff --git a/05_deliverables_mvp/faisabilite/v18_master_intake/tests/test_master_intake.py b/05_deliverables_mvp/faisabilite/v18_master_intake/tests/test_master_intake.py new file mode 100644 index 0000000..61153f2 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/v18_master_intake/tests/test_master_intake.py @@ -0,0 +1,290 @@ +"""Suite Phase 1 · Master Project Intake / Master Data Model V18. + +Vérifie le CONTRAT (pas un moteur métier — il n'y en a pas encore, audit §11) : +statuts/catégories canoniques · DataPoint traçable · garde canonique #9/#10 · +intégrité de l'Evidence Register · rétro-compat STRICTE avec brief.json V12 +(D-08) · cohérence schéma ↔ code. stdlib pur ; l'oracle jsonschema est utilisé +s'il est présent, sinon ignoré (comme les 17 skips « par design » du dépôt). +""" + +from __future__ import annotations + +import json +import os +import sys +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_MODULE = os.path.dirname(_HERE) +for p in (_MODULE,): + if p not in sys.path: + sys.path.insert(0, p) + +from mdmlib import ( # noqa: E402 + CANONICAL, + CATEGORIES, + STATUSES, + DataPoint, + Document, + DocumentRegister, + EvidenceRegister, + MasterDataError, + assert_not_canonical, + is_present, +) +import master_intake_gen as cli # noqa: E402 + +SCHEMA_PATH = os.path.join(_MODULE, "master_intake.schema.json") +BRIEF_SCHEMA_PATH = os.path.join(_MODULE, "..", "generator", "brief.schema.json") +FIXTURE = os.path.join(_MODULE, "fixtures", "intake_P01_coralis.json") +GEN_CANONICAL_PATH = os.path.join( + _MODULE, "..", "generator", "genlib", "model.py" +) + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +class TestStatusesCategories(unittest.TestCase): + def test_exactly_11_statuses_in_canonical_order(self): + self.assertEqual(len(STATUSES), 11) + self.assertEqual( + STATUSES, + ( + "VERIFIED", "CONFIRMED", "SOURCE_BASED", "CALCULATED", + "ESTIMATED", "ASSUMPTION", "TARGET", "BANK_REQUIREMENT", + "PENDING", "MISSING", "NOT_APPLICABLE", + ), + ) + + def test_exactly_5_categories(self): + self.assertEqual( + CATEGORIES, + ("INPUT", "TARGET", "ASSUMPTION", "BANK_REQUIREMENT", "CALCULATED"), + ) + + def test_is_present_only_false_for_pending_and_missing(self): + absent = {s for s in STATUSES if not is_present(s)} + self.assertEqual(absent, {"PENDING", "MISSING"}) + + +class TestSchemaMirrorsCode(unittest.TestCase): + """Le schéma machine et le code Python doivent porter les MÊMES enums (pas + de dérive silencieuse entre les deux surfaces du contrat).""" + + def setUp(self): + self.schema = _load(SCHEMA_PATH) + + def test_schema_is_draft07(self): + self.assertEqual( + self.schema["$schema"], "http://json-schema.org/draft-07/schema#" + ) + + def test_status_enum_matches_code(self): + enum = self.schema["definitions"]["data_status"]["enum"] + self.assertEqual(tuple(enum), STATUSES) + + def test_category_enum_matches_code(self): + enum = self.schema["definitions"]["data_category"]["enum"] + self.assertEqual(tuple(enum), CATEGORIES) + + def test_dataset_version_enum(self): + self.assertEqual( + self.schema["properties"]["dataset_version"]["enum"], ["V12", "V18"] + ) + + +class TestRetroCompatV12(unittest.TestCase): + """D-08 : master_intake = SUR-ENSEMBLE STRICT du brief.json V12.""" + + def setUp(self): + self.master = _load(SCHEMA_PATH) + self.brief = _load(BRIEF_SCHEMA_PATH) + + def test_master_includes_brief_by_ref(self): + refs = [a.get("$ref") for a in self.master.get("allOf", [])] + self.assertIn(self.brief["$id"], refs, + "brief.schema.json V12 doit être inclus via allOf/$ref (D-08 règle 2)") + + def test_no_v12_key_shadowed_with_conflicting_type(self): + # Toute clé V12 réintroduite dans les properties V18 doit garder un type + # compatible (rétro-compat parser Publiciste · D-08 règle 1). Ici V18 + # n'en réintroduit AUCUNE (extension pure) — on le prouve. + v12_keys = set(self.brief.get("properties", {})) + v18_keys = set(self.master.get("properties", {})) + self.assertEqual( + v12_keys & v18_keys, set(), + "les champs V18 sont ajoutés en EXTENSION, jamais en remplacement (D-08 règle 3)", + ) + + def test_dataset_version_field_present(self): + self.assertIn("dataset_version", self.master["properties"]) + + +class TestDataPoint(unittest.TestCase): + def test_valid_point(self): + dp = DataPoint(key="k", value=1, category="INPUT", status="VERIFIED") + self.assertTrue(dp.present) + self.assertEqual(dp.as_dict()["key"], "k") + + def test_unknown_status_rejected(self): + with self.assertRaises(MasterDataError): + DataPoint(key="k", value=1, category="INPUT", status="NOPE") + + def test_unknown_category_rejected(self): + with self.assertRaises(MasterDataError): + DataPoint(key="k", value=1, category="NOPE", status="VERIFIED") + + def test_empty_key_rejected(self): + with self.assertRaises(MasterDataError): + DataPoint(key=" ", value=1, category="INPUT", status="VERIFIED") + + def test_confidence_out_of_range_rejected(self): + with self.assertRaises(MasterDataError): + DataPoint(key="k", value=1, category="INPUT", status="VERIFIED", + confidence=1.5) + + def test_confidence_bool_rejected(self): + with self.assertRaises(MasterDataError): + DataPoint(key="k", value=1, category="INPUT", status="VERIFIED", + confidence=True) + + def test_source_based_constructs_without_inline_doc(self): + # La provenance d'un SOURCE_BASED peut venir d'un lien Evidence : le + # point seul se construit (la complétude est vérifiée au niveau dataset, + # cf. TestEvidenceRegister.test_dangling_evidence_detected). + dp = DataPoint(key="k", value="x", category="INPUT", status="SOURCE_BASED") + self.assertTrue(dp.present) + DataPoint(key="k", value="x", category="INPUT", status="SOURCE_BASED", + source_document="DOC-1") + + def test_absent_status_cannot_carry_value(self): + with self.assertRaises(MasterDataError): + DataPoint(key="k", value=42, category="INPUT", status="PENDING") + # None est OK pour une lacune + DataPoint(key="k", value=None, category="INPUT", status="MISSING") + + +class TestCanonicalGuard(unittest.TestCase): + """Les 6 canoniques #9/#10 ne peuvent jamais devenir un intake saisissable.""" + + def test_each_canonical_key_rejected(self): + for key in CANONICAL: + with self.assertRaises(MasterDataError): + assert_not_canonical(key) + with self.assertRaises(MasterDataError): + DataPoint(key=key, value="x", category="INPUT", status="VERIFIED") + + def test_canonical_values_match_generator_v12(self): + # Single source of truth : les valeurs canoniques V18 sont IDENTIQUES à + # celles du générateur V12 (audit §8 « réimposer identiques »). On les + # relit dans generator/genlib/model.py sans l'importer (évite tout effet + # de bord d'import du module de prod) et on exige l'égalité stricte. + ns: dict = {} + with open(GEN_CANONICAL_PATH, encoding="utf-8") as fh: + src = fh.read() + # extrait le littéral CANONICAL = {...} + start = src.index("CANONICAL = {") + end = src.index("}", start) + 1 + exec(src[start:end], ns) # noqa: S102 — littéral de constantes contrôlé + self.assertEqual(CANONICAL, ns["CANONICAL"]) + + +class TestEvidenceRegister(unittest.TestCase): + def setUp(self): + self.docs = DocumentRegister() + self.docs.add(Document(doc_id="D1", title="Pièce 1")) + + def test_duplicate_doc_id_rejected(self): + with self.assertRaises(MasterDataError): + self.docs.add(Document(doc_id="D1", title="doublon")) + + def test_link_to_unknown_doc_rejected(self): + ev = EvidenceRegister(self.docs) + with self.assertRaises(MasterDataError): + ev.link("k", "GHOST") + + def test_dangling_evidence_detected(self): + ev = EvidenceRegister(self.docs) + # SOURCE_BASED dont la pièce n'est pas cataloguée → pendouillant + dp = DataPoint(key="k", value="x", category="INPUT", + status="SOURCE_BASED", source_document="GHOST") + self.assertEqual(ev.dangling_evidence([dp]), ["k"]) + + def test_resolved_evidence_not_dangling(self): + ev = EvidenceRegister(self.docs) + dp = DataPoint(key="k", value="x", category="INPUT", + status="SOURCE_BASED", source_document="D1") + self.assertEqual(ev.dangling_evidence([dp]), []) + + def test_link_resolves_evidence(self): + ev = EvidenceRegister(self.docs) + dp = DataPoint(key="k", value="x", category="INPUT", + status="SOURCE_BASED", source_document=None) + # pas de source_document mais un lien Evidence vers D1 → résout + ev.link("k", "D1") + self.assertEqual(ev.dangling_evidence([dp]), []) + + +class TestPilotFixture(unittest.TestCase): + """Le fixture pilote P01 respecte le contrat et est un vrai sur-ensemble V12.""" + + def setUp(self): + self.intake = _load(FIXTURE) + + def test_is_superset_of_v12_brief(self): + # champ requis V12 présent + version V18 + champs V18 d'extension + self.assertEqual(self.intake["projet"], "P01") + self.assertEqual(self.intake["dataset_version"], "V18") + self.assertIn("data_register", self.intake) + + def test_synthetique_flagged(self): + self.assertTrue(self.intake.get("synthetique"), + "un fixture de test doit être marqué synthetique (jamais publiable)") + + def test_contract_holds_and_no_dangling(self): + points, ev = cli.build_registers(self.intake) + self.assertEqual(ev.dangling_evidence(points), []) + # honnêteté : la majorité des données sont des lacunes au démarrage + present = [p for p in points if p.present] + self.assertTrue(len(present) >= 1) + + def test_cli_validate_returns_zero(self): + self.assertEqual(cli.cmd_validate(FIXTURE), 0) + + def test_cli_maturity_returns_zero(self): + self.assertEqual(cli.cmd_maturity(FIXTURE), 0) + + def test_jsonschema_oracle_if_present(self): + try: + import jsonschema # noqa: F401 + except Exception: + self.skipTest("jsonschema absent — oracle structurel ignoré (par design)") + errors = cli._schema_validate(self.intake) + self.assertEqual(errors, [], f"fixture non conforme : {errors}") + + +class TestCliContractViolations(unittest.TestCase): + def test_bad_status_fixture_fails_validate(self): + import tempfile + bad = { + "projet": "P02", + "dataset_version": "V18", + "data_register": [ + {"key": "x", "value": 1, "category": "INPUT", "status": "BOGUS"} + ], + } + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, + encoding="utf-8") as fh: + json.dump(bad, fh) + path = fh.name + try: + self.assertEqual(cli.cmd_validate(path), 1) + finally: + os.unlink(path) + + +if __name__ == "__main__": + unittest.main(verbosity=2)