Compare commits
2 Commits
bd9f2b173a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 15eebb41ca | |||
| c1e5fd9a6e |
@@ -0,0 +1,5 @@
|
|||||||
|
# Artefacts de génération locale (jamais commités — produits à la demande).
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
out/
|
||||||
@@ -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.
|
||||||
@@ -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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <intake.json>
|
||||||
|
python3 master_intake_gen.py maturity <intake.json>
|
||||||
|
|
||||||
|
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} <intake.json>", 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))
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 🎯 V18 · Décisions Michel · D-07 + D-08 tranchées
|
||||||
|
**Date:** 2026-08-12 · **Autorisation:** Michel Roy · **Publication:** Gitea
|
||||||
|
|
||||||
|
## D-07 · APPROUVÉ · Formules standards Big4
|
||||||
|
|
||||||
|
**Décision Michel:** Claude Code DTP-Worker est autorisé à utiliser les **formules standards Big4** (Deloitte / KPMG / PwC / EY) pour toutes les métriques financières V18:
|
||||||
|
|
||||||
|
- **DCF** (Discounted Cash Flow) — standard NPV + terminal value + WACC
|
||||||
|
- **IRR / VAN** (Internal Rate of Return · Net Present Value) — standard time-value-of-money
|
||||||
|
- **DSCR** (Debt Service Coverage Ratio) — Net Operating Income / Debt Service annuel
|
||||||
|
- **LTV** (Loan-to-Value) — Loan Amount / Property Value
|
||||||
|
- **LTC** (Loan-to-Cost) — Loan Amount / Total Development Cost
|
||||||
|
- **Break-even analysis** — Fixed Costs / (Price - Variable Cost)
|
||||||
|
- **Sensitivity analysis** — ±10% swing sur variables clés (prix vente · CAPEX · absorption)
|
||||||
|
- **Downside / Stress case** — Monte Carlo simplifié 3 scénarios
|
||||||
|
|
||||||
|
**Sources autorisées:**
|
||||||
|
- Deloitte Real Estate Feasibility Standards
|
||||||
|
- KPMG Real Estate Valuation Handbook
|
||||||
|
- PwC Real Estate Investor Guidelines
|
||||||
|
- EY Global Real Estate Trends
|
||||||
|
- ULI (Urban Land Institute) standards
|
||||||
|
- FIABCI Real Estate Standards
|
||||||
|
|
||||||
|
**Contrainte:** chaque formule utilisée dans un livrable V18 doit être **sourcée explicitement** en note de bas de page ou annexe (nom de la source + année).
|
||||||
|
|
||||||
|
**Débloqué:** Moteurs Phase 4 (Financial Engine) et Phase 8 (Bankability Engine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D-08 · APPROUVÉ · Master Intake A1-A20 = sur-ensemble STRICT brief.json V12
|
||||||
|
|
||||||
|
**Décision Michel:** OUI · le Master Project Intake V18 (A1-A20) est un **sur-ensemble strict** du `brief.json` V12.
|
||||||
|
|
||||||
|
**Règles de migration:**
|
||||||
|
|
||||||
|
1. **Tout champ V12 existant** conserve son nom/clé exact dans V18 (rétro-compat parser Publiciste)
|
||||||
|
2. `brief.schema.json` (V12) devient un **sous-schéma inclus** dans `master_intake.schema.json` (V18)
|
||||||
|
3. **Nouveaux champs V18** ajoutés en extension · jamais en remplacement
|
||||||
|
4. **Parser V12 continue de fonctionner** sur les données V18 (lecture partielle · les champs V18 ignorés silencieusement par le vieux parser)
|
||||||
|
5. **Aucune migration destructive** — projets V12 existants restent lisibles/utilisables
|
||||||
|
6. **Le champ `dataset_version`** distingue V12 vs V18 pour éviter confusion
|
||||||
|
|
||||||
|
**Bénéfice:** transition douce · aucune interruption du pipeline V12 pendant la construction V18 · rétro-compat garantie sur brief.json déjà en production.
|
||||||
|
|
||||||
|
**Débloqué:** Phase 1 Master Data Model peut finaliser le schema fusionné.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Impact global sur Phase 1
|
||||||
|
|
||||||
|
Après ces 2 décisions:
|
||||||
|
- Phase 1 Master Project Intake peut être livré avec schema V12 rétro-compat ✅
|
||||||
|
- Phase 4 Financial Engine peut démarrer avec formules Big4 ✅
|
||||||
|
- Phase 8 Bankability Engine peut démarrer avec métriques Big4 ✅
|
||||||
|
- Phases 2, 3, 5, 6, 7 restent autonomes (pas de blocage supplémentaire)
|
||||||
|
|
||||||
|
## Décisions restantes OUVERTES (non bloquantes)
|
||||||
|
|
||||||
|
- **D-01** · PIE contrats · à trancher avant Phase 13 (Legal)
|
||||||
|
- **D-02** · Financement condition #4 · à trancher avant Phase 11 (Commercial + Bankability)
|
||||||
|
- **D-03** · Inventaire otoia/capabilities/chat.py · info technique · non urgent
|
||||||
|
- **D-04** · Inventaire config/projets_editor.py · info technique · non urgent
|
||||||
|
- **D-09** · Séparateur milliers · à trancher au polissage final (Phase 11+)
|
||||||
|
|
||||||
|
## Publication
|
||||||
|
|
||||||
|
Commit à venir · Gitea `oto-enterprise-os-dtp`
|
||||||
|
Memory: mise à jour registre décisions
|
||||||
|
Notification: DTP-Worker doit lire au prochain cycle et intégrer ces 2 décisions dans le développement Phase 1 + planification Phases 4/8
|
||||||
Reference in New Issue
Block a user