codmap 0.0.11__tar.gz → 0.0.13__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 (130) hide show
  1. {codmap-0.0.11/codmap.egg-info → codmap-0.0.13}/PKG-INFO +7 -5
  2. {codmap-0.0.11 → codmap-0.0.13}/README.md +6 -4
  3. {codmap-0.0.11 → codmap-0.0.13}/codemap/arch.py +2 -1
  4. {codmap-0.0.11 → codmap-0.0.13}/codemap/cli.py +32 -16
  5. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/griffe_extractor.py +87 -20
  6. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/union.py +22 -9
  7. {codmap-0.0.11 → codmap-0.0.13}/codemap/incremental.py +63 -18
  8. {codmap-0.0.11 → codmap-0.0.13}/codemap/model.py +7 -0
  9. {codmap-0.0.11 → codmap-0.0.13}/codemap/query.py +18 -5
  10. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/architecture.py +9 -7
  11. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/audit.py +7 -5
  12. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/check.py +12 -7
  13. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/impact.py +3 -1
  14. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/livingdocs.py +3 -2
  15. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/session.py +94 -10
  16. {codmap-0.0.11 → codmap-0.0.13/codmap.egg-info}/PKG-INFO +7 -5
  17. {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/SOURCES.txt +3 -0
  18. {codmap-0.0.11 → codmap-0.0.13}/pyproject.toml +1 -1
  19. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m1_query.py +9 -4
  20. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c29_lazy_imports.py +5 -5
  21. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c30f2_check_scope.py +1 -1
  22. codmap-0.0.13/tests/test_r1c44_absent_answers.py +197 -0
  23. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c45_repeat_union.py +55 -6
  24. codmap-0.0.13/tests/test_r1c47_incremental_resample.py +155 -0
  25. codmap-0.0.13/tests/test_r1c48_type_checking_imports.py +187 -0
  26. {codmap-0.0.11 → codmap-0.0.13}/LICENSE +0 -0
  27. {codmap-0.0.11 → codmap-0.0.13}/codemap/__init__.py +0 -0
  28. {codmap-0.0.11 → codmap-0.0.13}/codemap/apidiff.py +0 -0
  29. {codmap-0.0.11 → codmap-0.0.13}/codemap/diagnostics.py +0 -0
  30. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/__init__.py +0 -0
  31. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/attrflow.py +0 -0
  32. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/behavior.py +0 -0
  33. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/dataflow.py +0 -0
  34. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/dispatch.py +0 -0
  35. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/gsource.py +0 -0
  36. {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/roots.py +0 -0
  37. {codmap-0.0.11 → codmap-0.0.13}/codemap/freshness.py +0 -0
  38. {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/__init__.py +0 -0
  39. {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/base.py +0 -0
  40. {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/cocoindex.py +0 -0
  41. {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/gate.py +0 -0
  42. {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/gitnexus.py +0 -0
  43. {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/registry.py +0 -0
  44. {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/transport.py +0 -0
  45. {codmap-0.0.11 → codmap-0.0.13}/codemap/provenance.py +0 -0
  46. {codmap-0.0.11 → codmap-0.0.13}/codemap/scope.py +0 -0
  47. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/__init__.py +0 -0
  48. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/_scip_pb2.py +0 -0
  49. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/api_surface.py +0 -0
  50. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/apidiff.py +0 -0
  51. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/ctags.py +0 -0
  52. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/limits.py +0 -0
  53. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/mcp_server.py +0 -0
  54. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/mermaid.py +0 -0
  55. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/pack.py +0 -0
  56. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/rag.py +0 -0
  57. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/review.py +0 -0
  58. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/scip.py +0 -0
  59. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/semantic.py +0 -0
  60. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/server.py +0 -0
  61. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/subsystems.py +0 -0
  62. {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/vault.py +0 -0
  63. {codmap-0.0.11 → codmap-0.0.13}/codemap/store.py +0 -0
  64. {codmap-0.0.11 → codmap-0.0.13}/codemap/tomlio.py +0 -0
  65. {codmap-0.0.11 → codmap-0.0.13}/codemap/watch.py +0 -0
  66. {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/dependency_links.txt +0 -0
  67. {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/entry_points.txt +0 -0
  68. {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/requires.txt +0 -0
  69. {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/top_level.txt +0 -0
  70. {codmap-0.0.11 → codmap-0.0.13}/setup.cfg +0 -0
  71. {codmap-0.0.11 → codmap-0.0.13}/tests/test_epistemic.py +0 -0
  72. {codmap-0.0.11 → codmap-0.0.13}/tests/test_gitnexus_router.py +0 -0
  73. {codmap-0.0.11 → codmap-0.0.13}/tests/test_impact_depth.py +0 -0
  74. {codmap-0.0.11 → codmap-0.0.13}/tests/test_integrations.py +0 -0
  75. {codmap-0.0.11 → codmap-0.0.13}/tests/test_issue3_serve_freshness.py +0 -0
  76. {codmap-0.0.11 → codmap-0.0.13}/tests/test_livingdocs.py +0 -0
  77. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m0_api_surface.py +0 -0
  78. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m11_argcontract.py +0 -0
  79. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m12_dataflow.py +0 -0
  80. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m15_review.py +0 -0
  81. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m16_architecture.py +0 -0
  82. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m17_mcp.py +0 -0
  83. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m18_freshness.py +0 -0
  84. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m1_5_semantics.py +0 -0
  85. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m2_views.py +0 -0
  86. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m32_watch.py +0 -0
  87. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m3_serve.py +0 -0
  88. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m4_behavior.py +0 -0
  89. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m5_deep.py +0 -0
  90. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m6_repo_scope.py +0 -0
  91. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m7_dispatch.py +0 -0
  92. {codmap-0.0.11 → codmap-0.0.13}/tests/test_m9_family.py +0 -0
  93. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  94. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c13_grep_vs_graph.py +0 -0
  95. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c16_semantic.py +0 -0
  96. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c20_attribute_edges.py +0 -0
  97. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c21_flat_layout.py +0 -0
  98. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c22_source_visible_refs.py +0 -0
  99. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c23_hard_python.py +0 -0
  100. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c24_test_mapping.py +0 -0
  101. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c25_provenance.py +0 -0
  102. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c26_deep_union.py +0 -0
  103. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c27_config_honesty.py +0 -0
  104. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c28_limit_envelope.py +0 -0
  105. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c2_ctags.py +0 -0
  106. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c30_local_import_calls.py +0 -0
  107. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c30f1_reexport_calls.py +0 -0
  108. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c31_path_origin.py +0 -0
  109. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c32_report_json.py +0 -0
  110. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c33_query_signature.py +0 -0
  111. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c34_signature_kinds.py +0 -0
  112. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c35_contract_location.py +0 -0
  113. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c36_target_resolution.py +0 -0
  114. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c37_rule_mutation.py +0 -0
  115. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c38_tool_drift.py +0 -0
  116. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c3_arch_contract.py +0 -0
  117. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c3_dogfood.py +0 -0
  118. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c41_scope_membership.py +0 -0
  119. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c42_deep_tier_stability.py +0 -0
  120. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c43_incremental_splice.py +0 -0
  121. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c46_shadowed_definitions.py +0 -0
  122. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c4_complexity.py +0 -0
  123. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c5_apidiff.py +0 -0
  124. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c6_pack.py +0 -0
  125. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c7_edge_vocab.py +0 -0
  126. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c8_deadcode.py +0 -0
  127. {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c9_incremental.py +0 -0
  128. {codmap-0.0.11 → codmap-0.0.13}/tests/test_scip_export.py +0 -0
  129. {codmap-0.0.11 → codmap-0.0.13}/tests/test_scope.py +0 -0
  130. {codmap-0.0.11 → codmap-0.0.13}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.11
3
+ Version: 0.0.13
4
4
  Summary: Static analyzer that turns a package's source into a queryable code graph.
5
5
  Author-email: kogriv <kogriv@gmail.com>
6
6
  License-Expression: MIT
@@ -85,7 +85,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
85
85
  analysis → core 38 edges · indicators → core 22 · data → core 13
86
86
  violation ⚠ analysis ↔ core — one backward edge, written inside a function
87
87
  cycle pipeline → cache → pipeline — the classic Python import-order landmine
88
- lazy cycles 40 more, closed only by a function-local import — not import-time failures,
88
+ lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
89
89
  still mutual coupling: neither module can be extracted without the other
90
90
  coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
91
91
  concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
@@ -161,7 +161,7 @@ Optional extras: `mcp` (Model Context Protocol server), `scip` (protobuf, for SC
161
161
  ## Quickstart
162
162
 
163
163
  ```bash
164
- # build the canonical graph of a package
164
+ # build the canonical graph of a package (its directory name is the package name every id starts with)
165
165
  codemap build ./yourpkg -o graph.json
166
166
 
167
167
  # repo-scoped: add consumers (tests/examples) + docs for blast-radius/impact
@@ -271,8 +271,10 @@ unstable among 13 675). So one build misses such an edge about 1 time in 4, two
271
271
  1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
272
272
  regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
273
273
  than N runs carries `extras.seen`, `provenance.samples` records N and how many edges varied, and the
274
- note on the graph says both. Refused out loud on the fast tier (byte-stable already) and together with
275
- `--incremental` (a spliced graph cannot be resampled).
274
+ note on the graph says both. Refused out loud on the fast tier (byte-stable already). With `--incremental`
275
+ and `watch`, N becomes a property of the chain and every place the chain samples — base, fallback, each
276
+ tick's recompute — samples N times; measured on twenty real commits, that is what removes the chain's misses,
277
+ and a periodic full rebuild does not.
276
278
 
277
279
  **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
278
280
  [gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
@@ -49,7 +49,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
49
49
  analysis → core 38 edges · indicators → core 22 · data → core 13
50
50
  violation ⚠ analysis ↔ core — one backward edge, written inside a function
51
51
  cycle pipeline → cache → pipeline — the classic Python import-order landmine
52
- lazy cycles 40 more, closed only by a function-local import — not import-time failures,
52
+ lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
53
53
  still mutual coupling: neither module can be extracted without the other
54
54
  coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
55
55
  concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
@@ -125,7 +125,7 @@ Optional extras: `mcp` (Model Context Protocol server), `scip` (protobuf, for SC
125
125
  ## Quickstart
126
126
 
127
127
  ```bash
128
- # build the canonical graph of a package
128
+ # build the canonical graph of a package (its directory name is the package name every id starts with)
129
129
  codemap build ./yourpkg -o graph.json
130
130
 
131
131
  # repo-scoped: add consumers (tests/examples) + docs for blast-radius/impact
@@ -235,8 +235,10 @@ unstable among 13 675). So one build misses such an edge about 1 time in 4, two
235
235
  1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
236
236
  regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
237
237
  than N runs carries `extras.seen`, `provenance.samples` records N and how many edges varied, and the
238
- note on the graph says both. Refused out loud on the fast tier (byte-stable already) and together with
239
- `--incremental` (a spliced graph cannot be resampled).
238
+ note on the graph says both. Refused out loud on the fast tier (byte-stable already). With `--incremental`
239
+ and `watch`, N becomes a property of the chain and every place the chain samples — base, fallback, each
240
+ tick's recompute — samples N times; measured on twenty real commits, that is what removes the chain's misses,
241
+ and a periodic full rebuild does not.
240
242
 
241
243
  **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
242
244
  [gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
@@ -207,7 +207,8 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
207
207
  worst = sorted(lazy, key=lambda c: (len(c), c))
208
208
  violations.append(Violation(
209
209
  "no_lazy_cycles",
210
- f"{len(lazy)} dependency cycle(s) closed only by a function-local import",
210
+ f"{len(lazy)} dependency cycle(s) closed only by a non-eager import "
211
+ f"(function-local, or under `if TYPE_CHECKING:`)",
211
212
  modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
212
213
  ))
213
214
 
@@ -85,10 +85,9 @@ def _cmd_build(args) -> int:
85
85
  incr_info = None
86
86
  repeat = getattr(args, "repeat", None)
87
87
  repeat = 1 if repeat is None else repeat
88
- # R1-C45 / D7–D8: refused out loud, never swallowed. The fast tier is byte-stable and
89
- # CI pins it, so N runs there buy nothing and cost N minutes; and an incremental graph's
90
- # spliced part is an earlier build's sample by definition (R1-C43), so resampling only
91
- # the affected modules would leave `seen` relative to different N on neighbouring edges.
88
+ # R1-C45 / D7: refused out loud, never swallowed. The fast tier is byte-stable and CI
89
+ # pins it, so N runs there buy nothing and cost N minutes. With --incremental the flag
90
+ # is allowed since R1-C47: N is a property of the chain, and a different N restarts it.
92
91
  if repeat < 1:
93
92
  print("error: --repeat must be >= 1", file=sys.stderr)
94
93
  return 2
@@ -96,12 +95,13 @@ def _cmd_build(args) -> int:
96
95
  print(f"error: --repeat {repeat} needs --deep — the fast tier is byte-stable, so "
97
96
  "repeating it unions identical samples", file=sys.stderr)
98
97
  return 2
99
- if repeat > 1 and getattr(args, "incremental", False):
100
- print(f"error: --repeat {repeat} cannot be combined with --incremental — the spliced "
101
- "part of an incremental graph is an earlier build's sample and cannot be "
102
- "resampled; run a full build", file=sys.stderr)
103
- return 2
104
98
  if args.consumer or args.docs:
99
+ if getattr(args, "incremental", False):
100
+ # R1-C44 / D6: the flag used to be swallowed here without a word. Not an
101
+ # error — a stated boundary: the incremental path is single-package.
102
+ print("[note] --incremental was not applied: --consumer/--docs make this a "
103
+ "repo-scoped build, which is always full (the incremental path is "
104
+ "single-package only)", file=sys.stderr)
105
105
  graph = extract_repo(
106
106
  args.path,
107
107
  consumers=tuple(args.consumer or ()),
@@ -201,12 +201,15 @@ def _incremental_build(args):
201
201
  old_meta = read_meta(args.out)
202
202
  old_scope = (old_meta or {}).get("scope")
203
203
  if not (Path(args.out).exists() and old_scope):
204
- return extract(args.path, deep=args.deep), {"mode": "full", "affected": []}
204
+ return extract(args.path, deep=args.deep, repeat=getattr(args, "repeat", None) or 1), \
205
+ {"mode": "full", "affected": []}
205
206
  old_graph = store.load(args.out)
206
207
  new_scope = resolve_scope(args.path)
208
+ repeat = getattr(args, "repeat", None) or 1
207
209
  if old_graph.target != Path(args.path).resolve().name:
208
- return extract(args.path, deep=args.deep), {"mode": "full", "affected": []}
209
- return update_graph(old_graph, args.path, old_scope, new_scope, deep=args.deep)
210
+ return extract(args.path, deep=args.deep, repeat=repeat), {"mode": "full", "affected": []}
211
+ return update_graph(old_graph, args.path, old_scope, new_scope, deep=args.deep,
212
+ repeat=repeat)
210
213
 
211
214
 
212
215
  def _watch_build_argv(args) -> list[str]:
@@ -219,6 +222,8 @@ def _watch_build_argv(args) -> list[str]:
219
222
  argv = ["build", args.path, "-o", args.out, "--incremental"]
220
223
  if args.deep:
221
224
  argv.append("--deep")
225
+ if (getattr(args, "repeat", None) or 1) > 1:
226
+ argv += ["--repeat", str(args.repeat)] # R1-C47: N is a property of the chain
222
227
  for c in args.consumer or ():
223
228
  argv += ["--consumer", c]
224
229
  for d in args.docs or ():
@@ -261,6 +266,7 @@ def _cmd_watch(args) -> int:
261
266
  build_args = _argparse.Namespace(
262
267
  path=args.path, out=args.out, deep=args.deep, consumer=args.consumer,
263
268
  docs=args.docs, mode=args.mode, incremental=True,
269
+ repeat=getattr(args, "repeat", None) or 1,
264
270
  _argv=_watch_build_argv(args))
265
271
 
266
272
  reported: set[str] = set()
@@ -891,7 +897,9 @@ def build_parser() -> argparse.ArgumentParser:
891
897
  sub = p.add_subparsers(dest="command", required=True)
892
898
 
893
899
  b = sub.add_parser("build", help="Build the canonical graph from a package path.")
894
- b.add_argument("path", help="Path to the package directory (holds __init__.py).")
900
+ b.add_argument("path", help="Path to the package directory (holds __init__.py). Its "
901
+ "directory name is the package name every node id starts "
902
+ "with — a copy under another name answers to that name.")
895
903
  b.add_argument("-o", "--out", help="Write graph.json here (default: stdout JSON).")
896
904
  b.add_argument("--deep", action="store_true",
897
905
  help="Deep call resolution via jedi (richer, ~1 min; default fast).")
@@ -904,14 +912,17 @@ def build_parser() -> argparse.ArgumentParser:
904
912
  help="Consumer granularity: thin=per-file (default), full=per-function.")
905
913
  b.add_argument("--incremental", action="store_true",
906
914
  help="Reuse an existing --out graph + its scope sidecar: recompute "
907
- "only changed modules (R1-C9). Identical to a full build; much "
908
- "faster on --deep. Single-package only (no --consumer/--docs).")
915
+ "only changed modules (R1-C9). Byte-identical to a full build on "
916
+ "the fast tier; on --deep it carries the earlier build's jedi "
917
+ "sample forward (docs/incremental.md). Much faster on --deep. "
918
+ "Single-package only (no --consumer/--docs).")
909
919
  b.add_argument("--repeat", type=int, default=1, metavar="N",
910
920
  help="--deep only: run the jedi layer N times and union the samples. "
911
921
  "One deep build is one sample (a real edge was present in 75%% "
912
922
  "of single builds, measured); an edge seen in fewer than N runs "
913
923
  "carries extras.seen, provenance.samples records N. Costs N× the "
914
- "jedi time. Not combinable with --incremental.")
924
+ "jedi time, in parallel. With --incremental N becomes a property "
925
+ "of the chain: a different N restarts it from a full build.")
915
926
  b.set_defaults(func=_cmd_build)
916
927
 
917
928
  sc = sub.add_parser("scope", help="Resolve the input scope manifest (scope_id + profile), or --diff two.")
@@ -1015,6 +1026,11 @@ def build_parser() -> argparse.ArgumentParser:
1015
1026
  w.add_argument("--docs", action="append", metavar="PATH", help="Docs root. Repeatable.")
1016
1027
  w.add_argument("--mode", choices=["thin", "full"], default="thin",
1017
1028
  help="Consumer granularity (as for `build`).")
1029
+ w.add_argument("--repeat", type=int, default=1, metavar="N",
1030
+ help="--deep only: sample the jedi layer N times on every build this "
1031
+ "loop makes — base, fallback and each tick's recompute (R1-C47). "
1032
+ "Measured: an incremental chain's misses come from single samples, "
1033
+ "not from age.")
1018
1034
  w.add_argument("--cycles", type=int, default=None,
1019
1035
  help="Stop after N polls instead of running forever (CI / testing).")
1020
1036
  _add_poll_options(
@@ -140,15 +140,22 @@ def add_behavioral_layer(graph, root, module_name, search_path, *, deep: bool,
140
140
  only=attr_only)
141
141
 
142
142
 
143
- def _sample_worker(package_path: str, deep: bool) -> dict:
144
- """One full extraction in a child interpreter; returns the graph as a dict (R1-C45)."""
143
+ def _sample_worker(package_path: str, deep: bool, only=None) -> dict:
144
+ """One extraction in a child interpreter; returns the graph as a dict (R1-C45).
145
+
146
+ ``only`` (a set of module paths) restricts the two jedi passes to those modules —
147
+ the incremental hook (R1-C9), so a chain can resample just what a tick touched
148
+ (R1-C47). The structural base is rebuilt in every worker regardless: cheap,
149
+ deterministic, and cheaper than shipping it across processes.
150
+ """
145
151
  graph, root, module_name, search_path = build_structural(package_path)
146
- add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
152
+ add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
153
+ behavior_only=only, attr_only=only)
147
154
  return graph.to_dict()
148
155
 
149
156
 
150
157
  def collect_samples(package_path, *, deep: bool, runs: int,
151
- workers: int | None = None) -> list[Graph]:
158
+ workers: int | None = None, only=None) -> list[Graph]:
152
159
  """``runs`` independent samples of ``package_path``, each in a fresh interpreter.
153
160
 
154
161
  Lives here, beside the two functions the worker calls, so ``extract/union.py`` stays
@@ -161,7 +168,8 @@ def collect_samples(package_path, *, deep: bool, runs: int,
161
168
  ctx = multiprocessing.get_context("spawn")
162
169
  procs = max(1, min(runs, workers or os.cpu_count() or 1))
163
170
  with ctx.Pool(processes=procs) as pool:
164
- dicts = pool.starmap(_sample_worker, [(str(package_path), deep)] * runs)
171
+ dicts = pool.starmap(_sample_worker,
172
+ [(str(package_path), deep, set(only) if only else None)] * runs)
165
173
  samples = []
166
174
  for d in dicts:
167
175
  g = Graph.from_dict(d)
@@ -257,8 +265,9 @@ def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
257
265
  return report
258
266
 
259
267
 
260
- def _source_import_targets(module) -> list[tuple[str, str]]:
261
- """``(target, scope)`` for the imports griffe's module-level map does not carry.
268
+ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
269
+ """``([(target, scope)], eager targets)`` — the imports griffe's module-level map does
270
+ not carry, and the targets it carries correctly.
262
271
 
263
272
  Two families, one traversal, one parse — griffe records neither, and both used to be
264
273
  invisible to the import graph:
@@ -274,6 +283,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
274
283
  - **imports written in a class body** → scope ``"module"``. They run at
275
284
  class-definition time, i.e. at import time, so they are ordinary eager dependencies
276
285
  and *can* close a real import cycle. griffe does not record them either (measured).
286
+ - **imports under `if TYPE_CHECKING:`** (R1-C48 / issue #18) → scope
287
+ ``"type_checking"``. griffe *does* record these, as module-level — it has no notion
288
+ of a condition that is always false at run time — so the second value returned is
289
+ the set of targets imported at plain module level: the caller demotes griffe's entry
290
+ for a target that appears only under ``TYPE_CHECKING``.
277
291
 
278
292
  Cost discipline, and it is not theoretical: the first version of this walked every
279
293
  function's subtree separately, which is quadratic in nesting and cost the dogfood
@@ -285,13 +299,13 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
285
299
  try:
286
300
  src = module.source
287
301
  except Exception: # no source (namespace dir, synthetic)
288
- return []
302
+ return [], set()
289
303
  if not NESTED_IMPORT_HINT.search(src):
290
- return []
304
+ return [], set()
291
305
  try:
292
306
  tree = ast.parse(src)
293
307
  except (SyntaxError, ValueError):
294
- return [] # unreadable: D2's report owns this file
308
+ return [], set() # unreadable: D2's report owns this file
295
309
  modpath = module.canonical_path
296
310
  f = module_file(module)
297
311
  is_pkg = f is not None and f.name == "__init__.py"
@@ -304,9 +318,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
304
318
  return node.module or ""
305
319
 
306
320
  out: list[tuple[str, str]] = []
321
+ eager: set[str] = set() # targets imported at plain module level (griffe has them)
307
322
 
308
323
  def visit(node, scope: str | None) -> None:
309
- """``scope`` is None at module level (griffe has those), else module|function."""
324
+ """``scope`` is None at module level (griffe has those), else
325
+ module|function|type_checking."""
310
326
  for child in ast.iter_child_nodes(node):
311
327
  if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
312
328
  visit(child, "function")
@@ -314,9 +330,21 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
314
330
  # A class body runs at import time — unless we are already inside a
315
331
  # function, in which case the whole thing does not.
316
332
  visit(child, "function" if scope == "function" else "module")
333
+ elif isinstance(child, ast.If) and scope in (None, "type_checking") \
334
+ and (tc := _type_checking_branches(child)) is not None:
335
+ # R1-C48 (issue #18): `if TYPE_CHECKING:` never runs. Its body is a third
336
+ # scope; its `else` is ordinary module level. Inside a function the whole
337
+ # `if` is function-local already and is not looked at.
338
+ body_scope, else_scope = tc
339
+ for stmt in child.body:
340
+ visit_stmt(stmt, body_scope if body_scope else scope)
341
+ for stmt in child.orelse:
342
+ visit_stmt(stmt, else_scope if else_scope else scope)
317
343
  elif isinstance(child, ast.Import):
318
344
  if scope is not None:
319
345
  out.extend((a.name, scope) for a in child.names)
346
+ else:
347
+ eager.update(a.name for a in child.names)
320
348
  elif isinstance(child, ast.ImportFrom):
321
349
  target = resolve(child)
322
350
  if not target:
@@ -325,6 +353,8 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
325
353
  if scope is None:
326
354
  if star: # the D3 case: griffe records nothing
327
355
  out.append((target, "module"))
356
+ eager.add(target)
357
+ eager.update(f"{target}.{a.name}" for a in child.names if a.name != "*")
328
358
  continue
329
359
  # `from pkg.mod import name` → keep the member paths so the resolver walks
330
360
  # down to the containing module exactly as it does for the module-level
@@ -335,19 +365,49 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
335
365
  else:
336
366
  visit(child, scope)
337
367
 
368
+ def visit_stmt(stmt, scope: str | None) -> None:
369
+ """Route one statement through ``visit`` as if it were the only child."""
370
+ holder = ast.Module(body=[stmt], type_ignores=[])
371
+ visit(holder, scope)
372
+
338
373
  visit(tree, None)
339
- return out
374
+ return out, eager
375
+
376
+
377
+ def _type_checking_branches(node: ast.If) -> tuple[str | None, str | None] | None:
378
+ """``(scope of body, scope of else)`` for a recognised ``TYPE_CHECKING`` test, else None.
379
+
380
+ Recognised, narrowly (design D3): the bare name ``TYPE_CHECKING``, any attribute named
381
+ ``TYPE_CHECKING`` (``typing.TYPE_CHECKING``, ``t.TYPE_CHECKING``), and ``not`` of either,
382
+ which swaps the branches. A compound test (``TYPE_CHECKING or X``) is *not* recognised
383
+ and stays eager: a condition the tool cannot read is judged strictly, never leniently.
384
+ ``None`` in a slot means "the enclosing scope" (module level).
385
+ """
386
+ test, negated = node.test, False
387
+ if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
388
+ test, negated = test.operand, True
389
+ is_tc = ((isinstance(test, ast.Name) and test.id == "TYPE_CHECKING")
390
+ or (isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"))
391
+ if not is_tc:
392
+ return None
393
+ return (None, "type_checking") if negated else ("type_checking", None)
340
394
 
341
395
 
342
396
  def _collect(graph, obj, root, target_pkg, walk) -> None:
343
397
  if obj.kind.value == "module":
344
398
  _claim(obj, walk) # the root claims its own path before any member is walked
345
399
  _add_node(graph, obj, root)
400
+ # R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports) +
401
+ # R1-C48 (`if TYPE_CHECKING:`): everything griffe's module-level map does not
402
+ # carry, or carries under the wrong scope, in one parse.
403
+ nested, eager = _source_import_targets(obj)
404
+ type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
346
405
  for name, tgt in (obj.imports or {}).items():
347
- walk.imports.append((obj.canonical_path, tgt, "module"))
348
- # R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports):
349
- # everything griffe's module-level map does not carry, in one parse.
350
- for tgt, scope in _source_import_targets(obj):
406
+ # griffe files an import under `if TYPE_CHECKING:` as module-level; it never
407
+ # runs. Demote it unless the same target is also imported eagerly (D2).
408
+ scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
409
+ walk.imports.append((obj.canonical_path, tgt, scope))
410
+ for tgt, scope in nested:
351
411
  walk.imports.append((obj.canonical_path, tgt, scope))
352
412
  for name, member in obj.members.items():
353
413
  if member.is_alias:
@@ -421,6 +481,13 @@ def _emit_decorated_by(graph, obj) -> None:
421
481
 
422
482
  # -- pass 2: resolve export + import edges against known nodes ----------------
423
483
 
484
+ #: How early an import scope reaches its target: an edge carries the earliest (R1-C29 D2,
485
+ #: R1-C48 D2). ``module`` runs at import time, ``type_checking`` never, ``function`` when
486
+ #: the function runs — the middle one is ordered before ``function`` only so the label
487
+ #: names the construct actually written at module level.
488
+ _SCOPE_RANK = {"module": 0, "type_checking": 1, "function": 2}
489
+
490
+
424
491
  def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
425
492
  module_ids = sorted(
426
493
  (n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
@@ -449,7 +516,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
449
516
  # the eager import it is. `scope` only ever *weakens* to "function" for a pair that
450
517
  # has no module-level import at all — the edge says how the dependency is reached at
451
518
  # its earliest, never how it happens to appear last in the walk.
452
- ordered = sorted(imports, key=lambda t: t[2] == "function")
519
+ ordered = sorted(imports, key=lambda t: _SCOPE_RANK.get(t[2], 0))
453
520
 
454
521
  # pass A — package-qualified targets. Exact, and run first so that a pair reachable
455
522
  # both ways is recorded as exact rather than inferred.
@@ -464,7 +531,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
464
531
  if key in seen:
465
532
  continue
466
533
  seen.add(key)
467
- extras = {"scope": "function"} if scope == "function" else {}
534
+ extras = {"scope": scope} if scope != "module" else {}
468
535
  graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
469
536
 
470
537
  # pass B — flat layout (R1-C21): sibling modules importing each other by bare name
@@ -481,8 +548,8 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
481
548
  continue
482
549
  seen.add(key)
483
550
  extras = {"resolution": "flat"}
484
- if scope == "function":
485
- extras["scope"] = "function"
551
+ if scope != "module":
552
+ extras["scope"] = scope
486
553
  graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
487
554
 
488
555
 
@@ -36,7 +36,10 @@ An edge seen in fewer than N runs carries ``extras.seen: k``; nothing is written
36
36
  ``k == N``, so a single-sample build's edges are byte-identical to what they were. Node
37
37
  counters (``calls`` / ``attr_access``) count *sites*, which dedupe into edges — the
38
38
  measured node had three counter variants behind one flapping edge — so per node the
39
- variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie: first run).
39
+ variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie: the serialized
40
+ counter). Every choice between variants of one key is a **total order** over the candidates
41
+ (:func:`_rank`, :func:`_counter_rank`): the merged graph is a function of the set of
42
+ samples, and the same samples in any order produce the same bytes.
40
43
 
41
44
  **Every sample is a fresh process.** The share above was measured across processes —
42
45
  the consumer's 175 builds and the eight here. Repeating the behavioural layer *inside*
@@ -74,20 +77,30 @@ def _identity(e: Edge) -> tuple:
74
77
  json.dumps(e.extras, sort_keys=True, ensure_ascii=False))
75
78
 
76
79
 
77
- def _deeper(new: Edge, current: Edge) -> bool:
78
- return (new.extras.get("resolution") == "deep"
79
- and current.extras.get("resolution") != "deep")
80
+ def _rank(e: Edge) -> tuple:
81
+ """Total order among the variants of one identity key: ``deep`` beats any other
82
+ resolution, more call sites beat fewer, and the serialized ``extras`` break what is
83
+ left — so the winner is a function of the *set* of samples, never of the order they
84
+ arrived in. The consumer's hint on codemap#17: where two candidates are equally
85
+ legitimate, 'first one wins' is a dependence on arrival order, and one that a probe
86
+ permuting the *output* can never see."""
87
+ ex = e.extras
88
+ return (ex.get("resolution") == "deep", ex.get("callsites", 0),
89
+ json.dumps(ex, sort_keys=True, ensure_ascii=False))
90
+
91
+
92
+ def _counter_rank(c: dict) -> tuple:
93
+ return (-c.get("unresolved", 0), c.get("resolved", 0), json.dumps(c, sort_keys=True))
80
94
 
81
95
 
82
96
  def _better_counter(a: dict | None, b: dict | None) -> dict | None:
83
- """The counter variant that resolved more sites (``None`` never beats a value)."""
97
+ """The counter variant that resolved more sites (``None`` never beats a value);
98
+ a tie is broken by the serialized counter, not by which sample came first."""
84
99
  if a is None:
85
100
  return b
86
101
  if b is None:
87
102
  return a
88
- ka = (-a.get("unresolved", 0), a.get("resolved", 0))
89
- kb = (-b.get("unresolved", 0), b.get("resolved", 0))
90
- return b if kb > ka else a
103
+ return b if _counter_rank(b) > _counter_rank(a) else a
91
104
 
92
105
 
93
106
  def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
@@ -116,7 +129,7 @@ def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
116
129
  else:
117
130
  if key not in seen_this_run:
118
131
  entry[0] += 1
119
- if _deeper(e, entry[1]):
132
+ if _rank(e) > _rank(entry[1]):
120
133
  entry[1] = e
121
134
  seen_this_run.add(key)
122
135
 
@@ -54,10 +54,10 @@ import copy
54
54
  from pathlib import Path
55
55
 
56
56
  from codemap.extract.griffe_extractor import (
57
- add_behavioral_layer, build_structural, extract,
57
+ add_behavioral_layer, build_structural, collect_samples, extract,
58
58
  )
59
- from codemap.extract.union import BEHAVIOR_CALL_RES
60
- from codemap.model import Graph
59
+ from codemap.extract.union import BEHAVIOR_CALL_RES, merge_samples
60
+ from codemap.model import SPLICED_EDGE_TYPES, Graph
61
61
  from codemap.provenance import build_provenance, tool_identity
62
62
  from codemap.scope import diff_scopes
63
63
 
@@ -75,8 +75,9 @@ _BEHAVIOR_REF_RES = frozenset({"name", "annotation"})
75
75
  # it — or the fast tier's byte-identity to a full build (R1-C9) breaks on it.
76
76
  _BEHAVIORAL_EXTRAS = ("calls", "control", "complexity", "attr_access", "shadows")
77
77
  # Old edge types whose target landing in a changed/removed module makes the source
78
- # module stale (it must re-resolve). Only the spliced passes matter here.
79
- _DEP_EDGE_TYPES = frozenset({"calls", "accesses", "references"})
78
+ # module stale (it must re-resolve). Only the spliced passes matter here — and the serve
79
+ # layer reads the same set to know which ops answer from a spliced class (R1-C44).
80
+ _DEP_EDGE_TYPES = SPLICED_EDGE_TYPES
80
81
 
81
82
  # Above this fraction of modules affected, a full rebuild is cheaper (and trivially
82
83
  # correct) — no point splicing most of the graph.
@@ -159,21 +160,38 @@ def _splice_unaffected(new_graph, old_graph, unaffected, module_of) -> None:
159
160
  node.extras[k] = copy.deepcopy(old_extras[k])
160
161
 
161
162
 
162
- def _same_builder(provenance: dict, tier: str) -> bool:
163
- """Was the old graph produced by *this* codemap, on this tier? (R1-C25)
163
+ def _same_builder(provenance: dict, tier: str, repeat: int = 1) -> str | None:
164
+ """Why the old graph is *not* from this builder — ``None`` when it is (R1-C25).
164
165
 
165
166
  A graph with no provenance (pre-0.12) cannot answer, so it is treated as a
166
167
  different builder — the conservative direction: a needless full rebuild costs a
167
168
  minute, a silently stale graph costs a wrong answer.
169
+
170
+ R1-C47: the sample count is part of the builder. ``extras.seen`` on a spliced edge
171
+ means "k of N", and it can only mean that if N is the same N the chain has used all
172
+ along — a request with a different N starts a new chain from a full build.
168
173
  """
169
174
  if not provenance:
170
- return False
171
- return (provenance.get("tool") == tool_identity()
172
- and provenance.get("tier") == tier)
175
+ return "builder-changed"
176
+ if (provenance.get("tool") != tool_identity()
177
+ or provenance.get("tier") != tier):
178
+ return "builder-changed"
179
+ if ((provenance.get("samples") or {}).get("runs", 1)) != repeat:
180
+ return "samples-changed"
181
+ return None
182
+
183
+
184
+ def _full(package_path, *, deep: bool, repeat: int) -> Graph:
185
+ """A full build with the chain's sample count (R1-C47 / D2)."""
186
+ return extract(package_path, deep=deep, repeat=repeat)
187
+
188
+
189
+ def _count_seen(graph: Graph) -> int:
190
+ return sum(1 for e in graph.edges if "seen" in e.extras)
173
191
 
174
192
 
175
193
  def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dict,
176
- *, deep: bool = False) -> tuple[Graph, dict]:
194
+ *, deep: bool = False, repeat: int = 1) -> tuple[Graph, dict]:
177
195
  """Incrementally rebuild ``old_graph`` for the current source tree.
178
196
 
179
197
  Returns ``(graph, info)`` where ``info`` records the decision (``mode``:
@@ -186,16 +204,25 @@ def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dic
186
204
  (R1-C42): the spliced regions carry the *previous* build's sample, and the splice
187
205
  is self-perpetuating — see the module docstring (R1-C43). Such a graph is stamped
188
206
  ``provenance.incremental: true``.
207
+
208
+ ``repeat=N`` (R1-C47) makes every place this path samples — the fallback full build
209
+ and the recompute of the affected modules — take N samples in fresh interpreters,
210
+ merged as a full ``--repeat`` build would be. Measured on twenty real commits: the
211
+ chain's misses come from single samples, not from age, so this is the remedy and a
212
+ periodic full rebuild is not. N must equal the old graph's ``samples.runs`` or the
213
+ chain restarts from a full build (``reason: samples-changed``).
189
214
  """
190
215
  target_pkg = old_graph.target
191
216
  tier = "deep" if deep else "fast"
192
217
  # R1-C25: the input is not the only thing that can change. `unchanged` below decides
193
218
  # from the source tree alone, so an upgraded codemap over an untouched tree used to
194
219
  # return yesterday's graph built by yesterday's extractor — the exact confusion the
195
- # provenance block exists to name. A different tool or tier is a full rebuild.
196
- if not _same_builder(old_graph.provenance, tier):
197
- graph = extract(package_path, deep=deep)
198
- return graph, {"mode": "full", "affected": [], "reason": "builder-changed"}
220
+ # provenance block exists to name. A different tool, tier or sample count is a full
221
+ # rebuild.
222
+ reason = _same_builder(old_graph.provenance, tier, repeat)
223
+ if reason is not None:
224
+ return _full(package_path, deep=deep, repeat=repeat), \
225
+ {"mode": "full", "affected": [], "reason": reason}
199
226
 
200
227
  d = diff_scopes(old_scope, new_scope)
201
228
  changed = {m for p in d["changed"] if (m := _path_to_module(p, target_pkg))}
@@ -218,14 +245,32 @@ def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dic
218
245
  module_of) & module_ids
219
246
 
220
247
  if not module_ids or len(affected) >= _FULL_FALLBACK_FRACTION * len(module_ids):
248
+ if repeat > 1:
249
+ # R1-C47: the fallback used to be the chain's weakest point — a 63-module
250
+ # commit reset a --repeat 3 chain to one sample on the replay's third tick.
251
+ return _full(package_path, deep=deep, repeat=repeat), \
252
+ {"mode": "full", "affected": sorted(affected)}
221
253
  add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
222
254
  graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"))
223
255
  return graph, {"mode": "full", "affected": sorted(affected)}
224
256
 
225
- add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
226
- behavior_only=affected, attr_only=affected)
257
+ if repeat > 1:
258
+ # R1-C47 / D2: N fresh interpreters, each restricted to the affected modules;
259
+ # the structural base is rebuilt in each (deterministic, ~4 s) so the merge
260
+ # sees identical nodes. The spliced part keeps its own `seen` — same N (D1).
261
+ graph, stats = merge_samples(collect_samples(package_path, deep=deep,
262
+ runs=repeat, only=affected))
263
+ graph.loaded_schema = None
264
+ else:
265
+ add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
266
+ behavior_only=affected, attr_only=affected)
267
+ stats = {"runs": 1}
227
268
  unaffected = module_ids - affected
228
269
  _splice_unaffected(graph, old_graph, unaffected, module_of)
270
+ if repeat > 1:
271
+ # `unstable` describes the artifact a reader holds, not the part this tick
272
+ # touched: recount over recomputed and spliced edges alike.
273
+ stats = {"runs": repeat, "unstable": _count_seen(graph)}
229
274
  graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"),
230
- incremental=True)
275
+ incremental=True, samples=stats)
231
276
  return graph, {"mode": "incremental", "affected": sorted(affected)}
@@ -97,6 +97,13 @@ EDGE_TYPES = frozenset({
97
97
  "accesses", # function → attribute node it reads/writes (extras.access, R1-C20)
98
98
  })
99
99
 
100
+ # The subset of EDGE_TYPES an incremental build splices from the old graph for modules
101
+ # it did not recompute (``incremental.py``) — and therefore the classes a deep+incremental
102
+ # graph answers from an *earlier* build's jedi sample (R1-C43). Lives with the vocabulary
103
+ # because two layers that may not import each other need it: the extractor's union
104
+ # (R1-C45) and the serve layer, which derives from it which ops must say so (R1-C44 / D4).
105
+ SPLICED_EDGE_TYPES = frozenset({"calls", "accesses", "references"})
106
+
100
107
 
101
108
  @dataclass
102
109
  class Node: