ambitgraph 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {ambitgraph-0.1.0/ambitgraph.egg-info → ambitgraph-0.2.0}/PKG-INFO +1 -1
  2. ambitgraph-0.2.0/ambit_map/ambit-mapllm-contract.md +246 -0
  3. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/daemon.py +25 -6
  4. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/export.py +20 -3
  5. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/judged.py +86 -3
  6. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/kitdemo.py +4 -4
  7. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/library.py +88 -11
  8. ambitgraph-0.2.0/ambit_map/mapllm.py +594 -0
  9. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/objects.py +3 -3
  10. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/orchestrate.py +123 -31
  11. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/registry.py +162 -27
  12. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/serve.py +34 -4
  13. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/unitpage.py +44 -13
  14. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/__init__.py +1 -1
  15. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/cli.py +145 -11
  16. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/edges.py +16 -0
  17. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/pipeline.py +73 -3
  18. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/portal.py +49 -119
  19. ambitgraph-0.2.0/ambitgraph/reports.py +88 -0
  20. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/shell.py +524 -145
  21. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/symbols.py +188 -9
  22. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/tables.py +30 -6
  23. ambitgraph-0.2.0/ambitgraph/urcode.py +78 -0
  24. {ambitgraph-0.1.0 → ambitgraph-0.2.0/ambitgraph.egg-info}/PKG-INFO +1 -1
  25. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph.egg-info/SOURCES.txt +4 -0
  26. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/pyproject.toml +7 -4
  27. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/LICENSE +0 -0
  28. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/MANIFEST.in +0 -0
  29. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/README.md +0 -0
  30. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/__init__.py +0 -0
  31. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/__main__.py +0 -0
  32. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/ambit-map-enrich.md +0 -0
  33. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/cli.py +0 -0
  34. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/dialects/__init__.py +0 -0
  35. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/dialects/contract.py +0 -0
  36. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/dialects/express_pack.py +0 -0
  37. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/dialects/fastapi_pack.py +0 -0
  38. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/dialects/flask_pack.py +0 -0
  39. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/dialects/nextjs_pack.py +0 -0
  40. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/dialects/supabase_pack.py +0 -0
  41. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/families.py +0 -0
  42. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/graph.py +0 -0
  43. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/island_layout.py +0 -0
  44. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/islands.py +0 -0
  45. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/load.py +0 -0
  46. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/matched.py +0 -0
  47. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/model.py +0 -0
  48. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/page.py +0 -0
  49. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/paths.py +0 -0
  50. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambit_map/theme.py +0 -0
  51. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/WALKTHROUGH.md +0 -0
  52. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/__main__.py +0 -0
  53. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/cockpit.py +0 -0
  54. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/codelib.py +0 -0
  55. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/config.py +0 -0
  56. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/console.py +0 -0
  57. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/demo.py +0 -0
  58. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/docs_discovery.py +0 -0
  59. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/doctor.py +0 -0
  60. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/duplication.py +0 -0
  61. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/emit.py +0 -0
  62. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/engine_identity.py +0 -0
  63. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/estimate.py +0 -0
  64. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/eval.py +0 -0
  65. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/feeds.py +0 -0
  66. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/gate.py +0 -0
  67. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/gate_check.py +0 -0
  68. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/gate_server.py +0 -0
  69. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/harvest.py +0 -0
  70. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/inventory.py +0 -0
  71. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/jscalls.py +0 -0
  72. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/mds.py +0 -0
  73. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/merge.py +0 -0
  74. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/merge_jsp.py +0 -0
  75. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/parameterize.py +0 -0
  76. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/pycalls.py +0 -0
  77. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/ratify.py +0 -0
  78. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/report.py +0 -0
  79. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/sample.py +0 -0
  80. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/secretary.py +0 -0
  81. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/stages.py +0 -0
  82. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph/tasks.py +0 -0
  83. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph.egg-info/dependency_links.txt +0 -0
  84. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph.egg-info/entry_points.txt +0 -0
  85. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph.egg-info/requires.txt +0 -0
  86. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/ambitgraph.egg-info/top_level.txt +0 -0
  87. {ambitgraph-0.1.0 → ambitgraph-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ambitgraph
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Read-only codebase scan: reusable units, families, roles, ownership candidates, duplication.
5
5
  Author: AmbitGraph
6
6
  License: Proprietary
@@ -0,0 +1,246 @@
1
+ # The mapllm ingestion contract — the shapes your assistant's files must carry
2
+
3
+ ambit-map-enrich.md already states the law this document generalises: "Today's supported path
4
+ is Claude Code; ANY capable assistant can satisfy the same contract, because your artifacts are
5
+ plain JSON on your disk." That clause is the whole idea — this document turns it into an actual
6
+ schema, so an assistant that is not Claude Code (or a hand-written script) can write a
7
+ conforming file directly and be read exactly the same way.
8
+
9
+ The law stated plainly, again, because it never changes: everything in this document produces
10
+ *judgment*, not measurement. Nothing here is read, parsed, or counted by the deterministic scan.
11
+ The map keeps the two vocabularies visibly apart — a judged note always renders inside the
12
+ collapsed "Judged by your assistant" block, always carries the `judged` chip, and no judged
13
+ number is ever added to a measured number. A plane built from this contract DECORATES the map;
14
+ it never becomes authoritative over anything the scan itself measured.
15
+
16
+ ## Two ways to produce these files
17
+
18
+ 1. **The workflow-script loop** (ambit-map-enrich.md's own loop): run the script `pending/
19
+ <stage>.workflow.js` in your assistant session, then reduce its journal with `ambit harvest
20
+ <stage> --scan <run-dir> --journal <journal>`. The harvester folds one or many analysts'
21
+ records into the single `results/<stage>.json` file this document specifies below —
22
+ deduplicating, validating against the enumerations each stage declares, and degrading a
23
+ partial or malformed record rather than discarding the run's other work.
24
+ 2. **The direct-results door**: write `results/<stage>.json` yourself, in the exact shape this
25
+ document names for that stage, and skip the workflow-script loop entirely. mapllm's conductor
26
+ accepts a directly-written results file exactly as it accepts one `ambit harvest` produced —
27
+ the file on disk is the whole contract; nothing tracks how it got there.
28
+
29
+ Either way, a stage advances only once its `results/<stage>.json` exists; re-running the same
30
+ scan command against the same output directory picks up from there.
31
+
32
+ ## The stage results files
33
+
34
+ Every stage below is optional except `extract`, `cluster`, and `roles` — `vocab` and `ownership`
35
+ run only when the scan config declares a vocabulary or ownership areas; `suggest` runs only when
36
+ the draft has ambiguous units queued for a human's ruling.
37
+
38
+ ### The `extract` stage — `results/<stage>.json`
39
+
40
+ ```json
41
+ {
42
+ "units": [
43
+ {
44
+ "anchor": "helpers.py::shout",
45
+ "file": "helpers.py",
46
+ "hook": "shout",
47
+ "granularity": "component",
48
+ "role0": "structure_owner",
49
+ "hint": "a small formatting helper",
50
+ "ev": "imported and called by main.py",
51
+ "similar_to": []
52
+ }
53
+ ],
54
+ "uncovered": []
55
+ }
56
+ ```
57
+
58
+ `granularity` is one of `module` (whole-file scope), `component` (a cohesive unit inside a
59
+ file), or `fragment` (a small piece that composes into larger units) — or the empty string when
60
+ unknown. `role0` is one of `structure_owner` (this code DEFINES the shared structure), `filler`
61
+ (this code USES a structure defined elsewhere), or `unclear`. `anchor` is `file::hook` — the
62
+ stable pair every other stage's file joins on. `uncovered` names manifest paths no unit was
63
+ reported for; it is informational, never a refusal.
64
+
65
+ ### The `cluster` stage — `results/<stage>.json`
66
+
67
+ ```json
68
+ {
69
+ "families": [
70
+ {"name": "widgets", "members": ["helpers.py::shout", "main.py::run_report"]}
71
+ ]
72
+ }
73
+ ```
74
+
75
+ Every anchor from `extract.json` appears in exactly one family's `members`. A unit that fits no
76
+ real family belongs in one named `singletons`, never omitted. An optional `warnings` array
77
+ (strings) may accompany a run that had to degrade rather than discard part of the reduction.
78
+
79
+ ### The `roles` stage — `results/<stage>.json`
80
+
81
+ ```json
82
+ {
83
+ "roles": {
84
+ "helpers.py::shout": {"role": "structure_owner", "parent": "", "confidence": "high",
85
+ "family": "widgets"},
86
+ "main.py::run_report": {"role": "filler", "parent": "helpers.py::shout",
87
+ "confidence": "high", "family": "widgets"}
88
+ }
89
+ }
90
+ ```
91
+
92
+ Keyed by anchor. `role` is `structure_owner` | `filler` | `unclear`. `parent` is the anchor a
93
+ `filler` delegates to, written verbatim as anchor text (empty when there is none). `confidence`
94
+ is `high` | `medium` | `low`.
95
+
96
+ ### The `vocab` and `ownership` stages — `results/<stage>.json`
97
+
98
+ Written only when the scan config declares `vocabulary` (for `vocab.json`) or `areas` (for
99
+ `ownership.json`) — each a name-to-definition mapping the config itself states.
100
+
101
+ ```json
102
+ {
103
+ "assignments": {
104
+ "helpers.py::shout": {"family": "widget", "confidence": "high"}
105
+ },
106
+ "missing": [],
107
+ "invalid": []
108
+ }
109
+ ```
110
+
111
+ `vocab.json` uses the key `family`; `ownership.json` uses `area` in its place. The value is one
112
+ of the config's own declared names, or `"none"` (vocab) / `"unknown"` (ownership) — any other
113
+ value is rejected into `invalid`, never silently accepted. `missing` names anchors this file
114
+ assigns nothing to.
115
+
116
+ ### The `suggest` stage — `results/<stage>.json`
117
+
118
+ ```json
119
+ {
120
+ "suggestions": {
121
+ "helpers.py::shout": {"ruling": "keep", "rationale": "a tiny standalone helper, used once."}
122
+ },
123
+ "missing": [],
124
+ "invalid": []
125
+ }
126
+ ```
127
+
128
+ `ruling` is `keep` or `wrong` only — never `defer`; deciding later is the human reviewer's own
129
+ authority, not a machine suggestion. A missing or empty `rationale` is rejected into `invalid`.
130
+
131
+ ## The draft's own version and producer record
132
+
133
+ Once every required stage's results file exists, the scan assembles `map/draft_map.json` — the
134
+ one artifact this whole contract exists to make honest. It carries two fields no prior draft
135
+ ever had:
136
+
137
+ ```json
138
+ {
139
+ "v": 1,
140
+ "producer": {
141
+ "recorded_at": "2026-08-30T00:00:00Z",
142
+ "config_hash": "sha1:0123456789ab",
143
+ "engine_fingerprint": "…64 hex chars…"
144
+ }
145
+ }
146
+ ```
147
+
148
+ `v` is the draft's schema version, checked on load — see "Fail-closed" below for exactly what
149
+ counts as a match. `producer.tool`, when present, names in plain free text what produced the
150
+ stage results this draft was assembled from. Nothing in this contract names a producer for you:
151
+ a `results/<stage>.json` header carries no `producer.tool` unless something put one there — the
152
+ workflow-script loop does not name itself automatically. Today that something is `ambit harvest`'s
153
+ own `--producer` flag:
154
+
155
+ ambit harvest <stage> --scan "<run-dir>" --journal <journal> --producer "your assistant's name and version"
156
+
157
+ `--producer` is optional and free text — name any assistant or tool, or omit it entirely. A
158
+ direct-results-door author writing `results/<stage>.json` by hand carries the identical header
159
+ shape without touching this flag at all: `{"producer": {"tool": "..."}}` sitting on the results
160
+ file is all either path needs to produce. When the `extract` stage's own `results/<stage>.json`
161
+ header never named a producer (the flag was omitted, or a legacy file predates this contract),
162
+ `producer.tool` is **omitted entirely** from the draft, exactly as the example above shows — never
163
+ written as a placeholder string, never guessed. A key that is present is always a real name
164
+ someone gave; a key that is absent means no one gave one. Whitespace-only names (`" "`) count as
165
+ no name at all, on both the writing and the reading side, and are stripped before anything is
166
+ stored — `ambit harvest --producer " "` prints one plain line saying so rather than silently
167
+ writing a blank name. `producer.config_hash` is the scan's own configuration hash (the same
168
+ value `results/symbols.json` and `results/edges.json` already carry); it binds this draft to the
169
+ scan it was produced against, so a draft copied onto a differently-configured scan is caught
170
+ rather than silently misjoined. `producer.engine_fingerprint` mirrors the draft's own top-level
171
+ fingerprint field, absence-disclosed the identical way (an `engine_fingerprint_absent` flag
172
+ stands in when no fingerprint could be computed).
173
+
174
+ ## Fail-closed, on every branch
175
+
176
+ The reader that loads this draft never raises on anything it finds on disk, and never renders a
177
+ guess:
178
+
179
+ - No draft file at all — the normal, unremarkable state before judgment has run. Nothing is
180
+ shown, nothing is claimed.
181
+ - A draft with no `"v"` key — every draft written before this contract existed. It loads exactly
182
+ as it always has; the absence of a version is never treated as a mismatch.
183
+ - A draft whose `"v"` key is present but is anything other than the plain integer `1` — refused
184
+ by name, nothing from the draft is read further. This includes `"v": null` (declared but
185
+ unusable is not the same fact as absent), `"v": true` (a boolean is never accepted as an
186
+ integer, even where it happens to equal `1` by value), and `"v": 1.0` (a float is never accepted
187
+ in place of the declared integer schema version) — every one of these is a real, present value
188
+ that simply is not this reader's version `1`.
189
+ - A draft whose `producer.config_hash` disagrees with the scan actually being served, when both
190
+ values are present — refused by name: the facts were produced against a different scan.
191
+ - Anything else malformed (unparseable JSON, a top level that is not an object, a `units` field
192
+ that is not a list) — refused by name, the same as always.
193
+
194
+ A refused draft never renders as an empty or zero judged plane — a plane that did not load never
195
+ counts as a plane that loaded empty. Every other field this document does not name is tolerated
196
+ additively: an unrecognised key is ignored, never a reason to refuse the whole file.
197
+
198
+ ## What the acceptance of a real repository taught
199
+
200
+ The rules above are the shapes; running a real assistant against a real, unmodified public
201
+ repository end to end found six ways a conforming file — or the run that produces it — can still
202
+ mislead a reader even while it parses cleanly. Each is a rule for the assistant writing these
203
+ files directly, not a change to the shapes themselves.
204
+
205
+ - **`parent` is a verbatim anchor, or the empty string — never prose, and never an external
206
+ framework's name.** It is the exact `file::hook` text some other unit's own `anchor` field
207
+ carries, copied byte for byte, so a reader can follow it with a lookup, not a guess. It is
208
+ never a sentence describing the relationship ("extends the base template"), and never the name
209
+ of a class, component or framework this scan's own unit set does not contain: when a filler's
210
+ true parent lives OUTSIDE what this run analysed — a third-party base class, a library
211
+ component — the correct value is the empty string, the SAME value written when there is no
212
+ parent at all. The external case IS the empty-string case; it is never spelled out in its own
213
+ words instead.
214
+ - **Evidence carries conclusions, never scratch reasoning or self-corrections.** The `ev` field,
215
+ and every free-text evidence field this contract accepts, states what you found — never the
216
+ thinking that got you there, and never a correction written into the record after the fact
217
+ ("actually, on reflection, it might instead..."). A reader who has to debug a train of thought
218
+ to recover the actual claim was handed reasoning, not evidence.
219
+ - **Counts never go inside an anchor.** An anchor names WHICH unit a record is about — it is an
220
+ identity, never a place to carry a measurement. A number belongs in a field built to hold one,
221
+ or is left out; folding it into the anchor text itself (`"helpers.py::shout (12 calls)"`) turns
222
+ an identity into a claim, and the two must never be the same string.
223
+ - **Never write "not in this file set," or any other claim about the file list's horizon.** A
224
+ large repository is read in chunks, and each analyst sees only the subset it was handed — a
225
+ sentence asserting what is or is not present ANYWHERE in the repository is a claim about ground
226
+ this one pass never covered. Worse, the harvester's own reduction keeps every evidence sentence
227
+ it receives VERBATIM: a horizon claim that was true of one chunk's own narrow view is carried
228
+ unchanged into the assembled draft, where it now reads as a claim about the WHOLE repository —
229
+ and is simply false there. State only what this pass itself observed inside the files it was
230
+ actually given.
231
+ - **The number of stages a run produces is repository-dependent — never a fixed count to
232
+ expect.** `vocab` and `ownership` run only when the scan's own config declares them (see "The
233
+ stage results files" above); `suggest` runs only when at least one unit could not be accepted
234
+ in bulk (the next rule names every reason that can queue one). Watch until the conductor's own
235
+ exit; never assume in advance how many stages a given run will produce.
236
+ - **The `suggest` queue is every unit the draft could not accept in bulk, and each entry's own
237
+ `why` field names every reason that queued it, comma-joined, in the engine's own words.** Four
238
+ independent checks fill the queue: `role == "unclear"` writes `"role unclear"`; otherwise a low
239
+ `confidence` writes `"low role confidence"`; no structural family assigned by the `cluster`
240
+ stage writes `"no structural family"`; low vocabulary confidence from the `vocab` stage writes
241
+ `"low vocabulary confidence"` — a unit can carry more than one of these at once, and none of
242
+ them is about duplicate code. Rule on the entry's OWN stated `why`, never a guess at what queued
243
+ it: for `"role unclear"`/`"low role confidence"` the question is "was my own prior uncertainty
244
+ justified" — but the other two reasons ask a genuinely different question, and the `why` string
245
+ is what tells you which one you are actually answering. Read it before ruling; do not assume it
246
+ is always the hedging question.
@@ -295,7 +295,7 @@ def _http_post_stop(port, token, timeout_s, opener=None):
295
295
  # ---------------------------------------------------------------------------
296
296
 
297
297
 
298
- def child_argv(sys_executable, args, port, idle_minutes):
298
+ def child_argv(sys_executable, args, port, idle_minutes, cmd_name="map"):
299
299
  """The exact argv/env of the mechanism's step 2: the child is `python
300
300
  -P -m ambitgraph map <repo> --foreground --no-open` (the "-P" and the
301
301
  env's PYTHONPATH rule are orch.python_child()'s -- see there for what
@@ -311,8 +311,18 @@ def child_argv(sys_executable, args, port, idle_minutes):
311
311
  truthy value for them -- read with getattr and a default, the K5
312
312
  seam's own rule, so `args` may be a bare six-attribute Namespace built
313
313
  by a caller that has never heard of this wave's new flags. Returns
314
- (argv, env), not argv alone -- see orch.python_child()."""
315
- tail = ["map", str(args.repo), "--foreground", "--no-open"]
314
+ (argv, env), not argv alone -- see orch.python_child().
315
+
316
+ `cmd_name`, new for the mapllm wave (THE SERVE FLAVOR, PLAN.md section
317
+ 2 C1): the child's own subcommand word -- "map" (the default, `ambit
318
+ map`'s own posture, byte-unchanged) or "mapllm". Every existing caller
319
+ of this function never passes it, so every existing argv is
320
+ unaffected; ambit_map.mapllm.cmd_mapllm's own detached-launch path is
321
+ the one caller that passes cmd_name="mapllm", so a mapllm server's
322
+ detached child re-enters the SAME conductor loop that spawned it,
323
+ never a plain `map` child that would silently drop the auto-harvest
324
+ mechanism."""
325
+ tail = [cmd_name, str(args.repo), "--foreground", "--no-open"]
316
326
  if port is not None:
317
327
  tail += ["--port", str(port)]
318
328
  if idle_minutes:
@@ -865,7 +875,7 @@ def _status_impl(out_dir, repo, reader, prober, pid_checker):
865
875
  def launch(args, out_dir, repo, idle_minutes, budget_s=None,
866
876
  reader=None, prober=None, pid_checker=None, spawner=None,
867
877
  child_alive=None, clock=None, platform=None, sys_executable=None,
868
- poll_interval_s=0.2, cmd=None):
878
+ poll_interval_s=0.2, cmd=None, cmd_name="map"):
869
879
  """The mechanism's steps 1-4, a pure orchestration over injectable
870
880
  side effects. `out_dir`/`repo` are ALREADY RESOLVED (the same values
871
881
  orch.plan() would compute from args.repo/args.out) -- this function
@@ -886,6 +896,14 @@ def launch(args, out_dir, repo, idle_minutes, budget_s=None,
886
896
  this parameter (an existing test, an older caller) still gets a
887
897
  real, typeable stop instruction rather than a KeyError.
888
898
 
899
+ `cmd_name`, new for the mapllm wave (THE SERVE FLAVOR, PLAN.md section
900
+ 2 C1): threaded to child_argv() unchanged (see its own docstring), and
901
+ used to resolve `cmd` itself when the caller leaves it None -- "map"
902
+ (the default, byte-unchanged) or "mapllm". Every existing caller
903
+ either passes `cmd` explicitly (cmd_map does) or never heard of
904
+ `cmd_name` at all, so this default resolution changes nothing for
905
+ them.
906
+
889
907
  Returns (exit_code, lines_to_print, url_to_open_or_None). cmd_map is a
890
908
  thin caller: print each line, then orch.open_browser(url, not
891
909
  args.no_open) when url is not None."""
@@ -933,7 +951,8 @@ def launch(args, out_dir, repo, idle_minutes, budget_s=None,
933
951
  # DAEMON_PORT_TAKEN comparison below can tell "not given" apart from
934
952
  # "given, and it was DEFAULT_PORT".
935
953
  requested_port = getattr(args, "port", None)
936
- argv, env = child_argv(sys_executable, args, requested_port, idle_minutes)
954
+ argv, env = child_argv(sys_executable, args, requested_port, idle_minutes,
955
+ cmd_name=cmd_name)
937
956
  log_path = log_path_for(out_dir)
938
957
 
939
958
  spawned = {}
@@ -1005,6 +1024,6 @@ def launch(args, out_dir, repo, idle_minutes, budget_s=None,
1005
1024
  if health.get("damaged"):
1006
1025
  lines.append(reg.DAEMON_DAMAGED.format(reason=health["damaged"]))
1007
1026
  if cmd is None:
1008
- cmd = invocation_form() + " map"
1027
+ cmd = invocation_form() + " " + cmd_name
1009
1028
  lines.append(reg.DAEMON_OPEN.format(url=url, repo=args.repo, cmd=cmd))
1010
1029
  return 0, lines, url
@@ -33,6 +33,10 @@ MANIFEST_NAME = "ambitgraph-map.export.json" # U4 review fix (blocker): the
33
33
  # fresh off disk at export time (never embedded as a literal), so a wheel
34
34
  # install and a repo checkout both ship the SAME bytes setuptools packaged.
35
35
  ENRICH_DOC_NAME = "ambit-map-enrich.md"
36
+ # C3 (mapllm wave): the ingestion contract's published schema doc, copied
37
+ # beside every export the SAME way as ENRICH_DOC_NAME just above -- the
38
+ # generalisation of ambit-map-enrich.md's own producer-portability clause.
39
+ CONTRACT_DOC_NAME = "ambit-mapllm-contract.md"
36
40
 
37
41
  Row = collections.namedtuple("Row", "rel route_path raw_query depth")
38
42
 
@@ -264,7 +268,12 @@ def write_export(state, out_dir, route, render, now_iso):
264
268
 
265
269
  rows, graph_slugs = page_rows(model, dialects)
266
270
  total_nodes = len(getattr(model, "units", {})) + len(getattr(model, "tables", {}))
267
- foot = _footer_html(len(rows) + 1, len(graph_slugs), total_nodes) # +1 for map.html
271
+ # `rows` names every .html page this export writes (the manifest's
272
+ # own "files" list, minus the two .md docs appended after it) --
273
+ # map.html is a real row IN `rows` (page_rows()'s own docstring
274
+ # above, and the manifest comment below, both say so), so the
275
+ # footer's own page count is exactly len(rows), no adjustment.
276
+ foot = _footer_html(len(rows), len(graph_slugs), total_nodes)
268
277
 
269
278
  # Stale-file rule (§2.1), scoped by manifest (U4 review fix, blocker):
270
279
  # never shutil.rmtree, never a glob wider than the paths this export
@@ -295,10 +304,18 @@ def write_export(state, out_dir, route, render, now_iso):
295
304
  (out_dir / ENRICH_DOC_NAME).write_text(
296
305
  enrich_src.read_text(encoding="utf-8"), encoding="utf-8", newline="\n")
297
306
 
307
+ # C3 -- the SAME copy-beside step for the ingestion contract doc.
308
+ contract_src = Path(__file__).with_name(CONTRACT_DOC_NAME)
309
+ if contract_src.exists():
310
+ (out_dir / CONTRACT_DOC_NAME).write_text(
311
+ contract_src.read_text(encoding="utf-8"), encoding="utf-8", newline="\n")
312
+
298
313
  # The manifest names everything THIS export wrote -- map.html included --
299
314
  # so the NEXT export's _prune_stale unlinks exactly these paths and
300
315
  # nothing a user placed alongside them (U4 review fix, blocker).
301
316
  # map.html is a ROW now, so it is already in this list -- appending it
302
- # again would name it twice in the manifest.
303
- _write_manifest(out_dir, [row.rel for row in rows] + [ENRICH_DOC_NAME])
317
+ # again would name it twice in the manifest. C3: the contract doc joins
318
+ # ENRICH_DOC_NAME here for the identical reason -- an un-manifested copy
319
+ # would be a stray file the NEXT export cannot recognise as its own.
320
+ _write_manifest(out_dir, [row.rel for row in rows] + [ENRICH_DOC_NAME, CONTRACT_DOC_NAME])
304
321
  return index_text
@@ -44,6 +44,12 @@ class JudgedPlane:
44
44
  unjoined: tuple = () # tuple[JudgedUnit], uid == "", sorted by anchor
45
45
  families: tuple = () # tuple[str], sorted -- legend count ONLY in v1
46
46
  rows_total: int = 0 # joined + unjoined rows kept
47
+ # the ingestion contract's producer record (a sibling engine module's
48
+ # draft writer stamps this): the free-text tool name, carried onto the
49
+ # plane ONLY when the draft actually named one -- "" otherwise, never
50
+ # guessed. A renderer may swap its generic wording for this when it is
51
+ # non-empty; this module renders nothing itself.
52
+ producer_tool: str = ""
47
53
 
48
54
 
49
55
  @dataclass(frozen=True)
@@ -52,6 +58,32 @@ class DamagedJudged:
52
58
  run_dir: str
53
59
 
54
60
 
61
+ # the ingestion contract's version discipline -- duplicated BY VALUE from
62
+ # the engine's own draft-writer module (the SAME number, never imported:
63
+ # this file speaks stdlib only). Bumped only for a BREAKING shape change.
64
+ DRAFT_SCHEMA_VERSION = 1
65
+
66
+
67
+ class DraftVersionMismatch(ValueError):
68
+ """Raised only to be caught immediately below; DamagedJudged.reason
69
+ carries just this class's OWN NAME (never a version number, never any
70
+ other byte of the draft). Fires when "v" is PRESENT and != 1 -- a
71
+ schema this reader does not speak. A draft with no "v" key at all is
72
+ NOT this: every draft written before the version key existed predates
73
+ it entirely, and loads exactly as it always has (the absence law)."""
74
+
75
+
76
+ class DraftProducerConfigMismatch(ValueError):
77
+ """Raised only to be caught immediately below; DamagedJudged.reason
78
+ carries just this class's own name. Fires only when BOTH the draft's
79
+ own producer.config_hash and the caller's run_config_hash are non-
80
+ empty strings AND they differ: the facts in this draft were produced
81
+ against a different scan configuration than the one being served now.
82
+ The F6 absence law, verbatim (a sibling engine-side loader keeps the
83
+ identical guard for its own two artifacts): either side absent never
84
+ fires."""
85
+
86
+
55
87
  @dataclass(frozen=True)
56
88
  class PendingScript:
57
89
  stage: str
@@ -69,13 +101,20 @@ def _s(row, key):
69
101
  return v if isinstance(v, str) else ""
70
102
 
71
103
 
72
- def load_judged(run_dir, known_uids=()):
104
+ def load_judged(run_dir, known_uids=(), run_config_hash=""):
73
105
  """-> JudgedPlane | None | DamagedJudged. Fail-closed -- never raises on
74
106
  any file-system or content state; callers pass a real path.
75
107
 
76
108
  None is the normal Layer-1 state (no draft yet, or run_dir is not a
77
109
  directory at all). DamagedJudged names only the exception CLASS -- no
78
- byte of the file's content is ever echoed."""
110
+ byte of the file's content is ever echoed.
111
+
112
+ `run_config_hash` (the ingestion contract's cross-artifact guard): the
113
+ CURRENT run's own config hash, computed by the caller (this module
114
+ imports no engine code and touches no scan config itself). Compared
115
+ against the draft's own producer.config_hash ONLY when both sides are
116
+ non-empty strings -- the F6 absence law, verbatim: omitted or ""
117
+ never fires a mismatch."""
79
118
  path = Path(run_dir).joinpath(*DRAFT_REL)
80
119
  try:
81
120
  text = path.read_text(encoding="utf-8")
@@ -94,6 +133,35 @@ def load_judged(run_dir, known_uids=()):
94
133
  data = json.loads(text)
95
134
  if not isinstance(data, dict):
96
135
  raise ValueError("draft is not an object")
136
+ # the version gate, BACKWARD HONEST: absent "v" predates this
137
+ # key entirely and is never a mismatch; present and wrong is.
138
+ # PRESENT-BUT-NULL TRAP (adversarial finding): checking
139
+ # `v is not None` treats {"v": null} as absent -- but a
140
+ # DECLARED, unusable value is not the same fact as no value at
141
+ # all ("declared-but-unusable is not absent"), so membership
142
+ # ("v" in data) is the only honest absence test, never `is not`.
143
+ # TYPE TRAP (the other half of the same finding): JSON's
144
+ # {"v": true} and {"v": 1.0} both deserialize to Python values
145
+ # that compare EQUAL to 1 by value (bool is an int subclass;
146
+ # 1.0 == 1) -- a bare `!= DRAFT_SCHEMA_VERSION` would silently
147
+ # accept either as "version 1". Only a real, non-bool int
148
+ # equal to DRAFT_SCHEMA_VERSION passes; every other declared
149
+ # shape (null, bool, float, string, wrong int, ...) mismatches.
150
+ if "v" in data:
151
+ v = data["v"]
152
+ if isinstance(v, bool) or not isinstance(v, int) \
153
+ or v != DRAFT_SCHEMA_VERSION:
154
+ raise DraftVersionMismatch("v")
155
+ # the F6-style config_hash cross-artifact guard: only fires
156
+ # when the draft's own producer.config_hash AND the caller's
157
+ # run_config_hash are both non-empty strings that disagree.
158
+ producer = data.get("producer")
159
+ draft_config_hash = (producer.get("config_hash")
160
+ if isinstance(producer, dict) else None)
161
+ if (isinstance(draft_config_hash, str) and draft_config_hash
162
+ and isinstance(run_config_hash, str) and run_config_hash
163
+ and draft_config_hash != run_config_hash):
164
+ raise DraftProducerConfigMismatch("config_hash")
97
165
  rows = data.get("units")
98
166
  if not isinstance(rows, list):
99
167
  raise ValueError("draft names no units list")
@@ -146,8 +214,23 @@ def load_judged(run_dir, known_uids=()):
146
214
  names.add(n)
147
215
  families = tuple(sorted(names))
148
216
 
217
+ # the producer's tool name, carried onto the plane ONLY when the
218
+ # draft actually recorded one -- never guessed. `producer` was
219
+ # already read (and type-checked) by the config_hash gate above.
220
+ # Stripped and re-checked for emptiness (the SAME whitespace rule
221
+ # the writer applies) so a hand-authored draft's " " reads back
222
+ # exactly as absent, never as a blank-looking "named" producer.
223
+ producer_tool = ""
224
+ if isinstance(producer, dict):
225
+ t = producer.get("tool")
226
+ if isinstance(t, str):
227
+ stripped = t.strip()
228
+ if stripped:
229
+ producer_tool = stripped
230
+
149
231
  return JudgedPlane(run_dir=str(run_dir), by_uid=by_uid, unjoined=unjoined,
150
- families=families, rows_total=rows_total)
232
+ families=families, rows_total=rows_total,
233
+ producer_tool=producer_tool)
151
234
  except Exception as exc:
152
235
  return DamagedJudged(reason=type(exc).__name__, run_dir=str(run_dir))
153
236
 
@@ -207,9 +207,9 @@ def _table_chips_block():
207
207
 
208
208
  def _symtable_block():
209
209
  """U2 (wave/u2-nodes) additive: demonstrates the three new emitters
210
- (sub_head, sym_table, chip_link) the unit page's "Blocks — what it
211
- holds" needs. Appended after every U0/U1 block -- no existing block's
212
- bytes move."""
210
+ (sub_head, sym_table, chip_link) the unit page's "Blocks — what the
211
+ scan read" needs. Appended after every U0/U1 block -- no existing
212
+ block's bytes move."""
213
213
  demo = (
214
214
  ob.sub_head("Includes — what this file pulls in")
215
215
  + ob.sym_table(
@@ -219,7 +219,7 @@ def _symtable_block():
219
219
  + ob.chip_link("/graph?u=app/main.py", "See it on the Graph →"))
220
220
  body = demo + ob.code('objects.sub_head(text) / objects.sym_table(headers, rows) / '
221
221
  'objects.chip_link(href, text)')
222
- return ob.block("kd-symtable", "blocks — what it holds", body, shown=True)
222
+ return ob.block("kd-symtable", "blocks — what the scan read", body, shown=True)
223
223
 
224
224
 
225
225
  def _code_block():