chipgraph 0.1.0__py3-none-any.whl

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 (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,647 @@
1
+ """The build graph: rule instances, their dependency edges, and staleness.
2
+
3
+ A `BuildGraph` is built from a set of `RuleSpec` (see `rules.py`) and a `ForeachResolver`
4
+ that expands `foreach` selectors into concrete parameter sets. Rules become `RuleInstance`
5
+ nodes once every output template and input selector is substituted with concrete params;
6
+ edges follow from one instance's inputs matching another instance's outputs (DESIGN 3.4,
7
+ 6.3).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import heapq
13
+ import re
14
+ from collections.abc import Iterable, Mapping
15
+ from pathlib import PurePosixPath
16
+ from types import MappingProxyType
17
+ from typing import Literal, Protocol, runtime_checkable
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from chipgraph.core.contracts import (
22
+ ArtifactKind,
23
+ ArtifactRef,
24
+ InputSpec,
25
+ RuleInstance,
26
+ RuleSpec,
27
+ Sha256,
28
+ )
29
+ from chipgraph.core.engine.targets import TargetRegistry
30
+ from chipgraph.core.state.artifacts import hash_inputs
31
+
32
+ _PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}")
33
+
34
+ _KIND_BY_SUFFIX: dict[str, ArtifactKind] = {
35
+ ".sv": "rtl",
36
+ ".v": "rtl",
37
+ ".svh": "rtl",
38
+ ".vh": "rtl",
39
+ ".py": "script",
40
+ ".md": "doc",
41
+ ".yml": "config",
42
+ ".yaml": "config",
43
+ ".toml": "config",
44
+ ".json": "report",
45
+ ".log": "report",
46
+ ".xml": "report",
47
+ ".svg": "diagram",
48
+ ".png": "diagram",
49
+ ".drawio": "diagram",
50
+ }
51
+
52
+
53
+ class GraphError(Exception):
54
+ """Raised for any problem building or querying a `BuildGraph`."""
55
+
56
+
57
+ def kind_for(path: str) -> ArtifactKind:
58
+ """Return the `ArtifactKind` for a file path, based on its extension."""
59
+ suffix = PurePosixPath(path).suffix
60
+ return _KIND_BY_SUFFIX.get(suffix, "other")
61
+
62
+
63
+ @runtime_checkable
64
+ class ForeachResolver(Protocol):
65
+ """Expands a rule's `foreach` selector into concrete parameter sets.
66
+
67
+ The real resolver (M1) queries the Design Model, e.g. `model.plan(block).modules`.
68
+ """
69
+
70
+ def expand(self, expr: str) -> list[dict[str, str]]:
71
+ """Return one dict of param values per instance the selector expands to."""
72
+ ...
73
+
74
+
75
+ class ForeachEntry(BaseModel):
76
+ """One instance a `foreach` selector expands to, with what the graph enforces for it.
77
+
78
+ A plain `ForeachResolver` only gives `params`. A resolver that expands from data a
79
+ person approved (e.g. a plan's modules) can also bound each instance:
80
+
81
+ - `inputs`: extra repo-relative paths the instance consumes (after the rule's own
82
+ inputs), so it depends on whatever produces them;
83
+ - `after`: the params (without the base params) of other entries of the *same*
84
+ expansion that this instance runs after: an ordering edge between instances of
85
+ one rule;
86
+ - `writes`: when set, every output of the instance must be one of these paths;
87
+ - `vars`: extra values for the rule's `{name}` placeholders (outputs and inputs) that
88
+ are not params, so they never change the instance id (e.g. a directory the
89
+ project's layout gives the block). A param of the same name wins.
90
+ """
91
+
92
+ model_config = ConfigDict(extra="forbid", frozen=True)
93
+
94
+ params: dict[str, str] = Field(description="The instance's param values.")
95
+ inputs: tuple[str, ...] = Field(default=(), description="Extra input paths.")
96
+ after: tuple[dict[str, str], ...] = Field(
97
+ default=(), description="Params of sibling entries this one runs after."
98
+ )
99
+ writes: frozenset[str] | None = Field(
100
+ default=None, description="The only paths the instance may output, when set."
101
+ )
102
+ vars: dict[str, str] = Field(
103
+ default_factory=dict,
104
+ description="Placeholder values that are not params (they do not change the id).",
105
+ )
106
+
107
+
108
+ @runtime_checkable
109
+ class EntryForeachResolver(ForeachResolver, Protocol):
110
+ """A `ForeachResolver` whose expansion also carries inputs, ordering and write sets."""
111
+
112
+ def expand_entries(self, expr: str) -> list[ForeachEntry]:
113
+ """Return one `ForeachEntry` per instance the selector expands to."""
114
+ ...
115
+
116
+
117
+ class StaticForeach:
118
+ """A `ForeachResolver` backed by a fixed mapping, for tests and simple builds."""
119
+
120
+ def __init__(self, values: Mapping[str, list[dict[str, str]]]) -> None:
121
+ self._values = values
122
+
123
+ def expand(self, expr: str) -> list[dict[str, str]]:
124
+ try:
125
+ param_sets = self._values[expr]
126
+ except KeyError:
127
+ raise GraphError(f"no foreach values registered for {expr!r}") from None
128
+ return [dict(params) for params in param_sets]
129
+
130
+
131
+ def _substitute(template: str, params: Mapping[str, str], *, rule_id: str) -> str:
132
+ """Substitute every `{name}` placeholder in `template` with a value from `params`.
133
+
134
+ Supports dotted names such as `{module.name}` by looking up that literal key in
135
+ `params` (no nested attribute traversal). Raises `GraphError` naming the rule and
136
+ the placeholder if a value is missing.
137
+ """
138
+
139
+ def _replace(match: re.Match[str]) -> str:
140
+ key = match.group(1)
141
+ if key in params:
142
+ return params[key]
143
+ # A `{KEY}` placeholder whose lower-cased name is a param means "the upper-cased
144
+ # value", so a layout convention like `TINY_{BLOCK}_MAS.md` resolves from a
145
+ # `block` param (matching `ingest`'s `_fill`, DESIGN.md 4.x) without adding a
146
+ # second param that would change instance ids.
147
+ lowered = key.lower()
148
+ if key.isupper() and lowered in params:
149
+ return params[lowered].upper()
150
+ raise GraphError(f"rule {rule_id!r}: no value for placeholder '{{{key}}}' in {template!r}")
151
+
152
+ return _PLACEHOLDER_RE.sub(_replace, template)
153
+
154
+
155
+ def _ref_key(ref: ArtifactRef) -> str:
156
+ """The producer/staleness lookup key for an `ArtifactRef`: `"<repo>:<path-or-model_key>"`."""
157
+ locator = ref.path if ref.path is not None else ref.model_key
158
+ return f"{ref.repo}:{locator}"
159
+
160
+
161
+ def _entries(
162
+ rule: RuleSpec,
163
+ resolver: ForeachResolver,
164
+ base_params: Mapping[str, str] | None,
165
+ ) -> list[ForeachEntry]:
166
+ base = dict(base_params or {})
167
+ if rule.foreach is None:
168
+ return [ForeachEntry(params=dict(base))]
169
+ if isinstance(resolver, EntryForeachResolver):
170
+ entries = resolver.expand_entries(rule.foreach)
171
+ else:
172
+ entries = [ForeachEntry(params=params) for params in resolver.expand(rule.foreach)]
173
+ return [entry.model_copy(update={"params": {**base, **entry.params}}) for entry in entries]
174
+
175
+
176
+ def _parse_target(target: str) -> tuple[str, dict[str, str]]:
177
+ """Split a `select()` target into a rule id and the (possibly empty) params filter."""
178
+ if "[" not in target:
179
+ return target, {}
180
+ rule_id, _, rest = target.partition("[")
181
+ if not rest.endswith("]"):
182
+ raise GraphError(f"malformed target {target!r}: expected a closing ']'")
183
+ body = rest[:-1]
184
+ params: dict[str, str] = {}
185
+ if body:
186
+ for pair in body.split(","):
187
+ key, sep, value = pair.partition("=")
188
+ if not sep:
189
+ raise GraphError(f"malformed target {target!r}: expected 'key=value' in {pair!r}")
190
+ params[key.strip()] = value.strip()
191
+ return rule_id, params
192
+
193
+
194
+ class BuildGraph:
195
+ """An immutable build graph: rule instances, their dependency edges and producers."""
196
+
197
+ def __init__(
198
+ self,
199
+ *,
200
+ instances: dict[str, RuleInstance],
201
+ rules: dict[str, RuleSpec],
202
+ producers: dict[str, str],
203
+ deps: dict[str, tuple[str, ...]],
204
+ dependents: dict[str, tuple[str, ...]],
205
+ external: frozenset[str],
206
+ spec_fragments: dict[str, str],
207
+ targets: TargetRegistry | None = None,
208
+ ) -> None:
209
+ self.instances = MappingProxyType(dict(instances))
210
+ self.targets = targets
211
+ self.rules = MappingProxyType(dict(rules))
212
+ self.producers = MappingProxyType(dict(producers))
213
+ self.spec_fragments = MappingProxyType(dict(spec_fragments))
214
+ self._deps = MappingProxyType(dict(deps))
215
+ self._dependents = MappingProxyType(dict(dependents))
216
+ self._external = external
217
+
218
+ def deps(self, instance_id: str) -> tuple[str, ...]:
219
+ """The instance ids `instance_id` directly depends on."""
220
+ return self._deps[instance_id]
221
+
222
+ def dependents(self, instance_id: str) -> tuple[str, ...]:
223
+ """The instance ids that directly depend on `instance_id`."""
224
+ return self._dependents[instance_id]
225
+
226
+ def topo_order(self) -> list[str]:
227
+ """A deterministic topological order: among ready nodes, the lowest id goes first."""
228
+ indegree = {iid: len(self._deps[iid]) for iid in self.instances}
229
+ heap = sorted(iid for iid, count in indegree.items() if count == 0)
230
+ heapq.heapify(heap)
231
+ order: list[str] = []
232
+ while heap:
233
+ iid = heapq.heappop(heap)
234
+ order.append(iid)
235
+ for dependent in self._dependents[iid]:
236
+ indegree[dependent] -= 1
237
+ if indegree[dependent] == 0:
238
+ heapq.heappush(heap, dependent)
239
+ return order
240
+
241
+ def ready(self, done: set[str], running: set[str] = frozenset()) -> list[str]: # type: ignore[assignment]
242
+ """Instances whose deps are all in `done`, that are not themselves done or running."""
243
+ return sorted(
244
+ iid
245
+ for iid in self.instances
246
+ if iid not in done
247
+ and iid not in running
248
+ and all(dep in done for dep in self._deps[iid])
249
+ )
250
+
251
+ def downstream(self, instance_id: str) -> set[str]:
252
+ """Every instance transitively depending on `instance_id`."""
253
+ return self._reachable(instance_id, self._dependents)
254
+
255
+ def _upstream(self, instance_id: str) -> set[str]:
256
+ """Every instance `instance_id` transitively depends on."""
257
+ return self._reachable(instance_id, self._deps)
258
+
259
+ @staticmethod
260
+ def _reachable(start: str, edges: Mapping[str, tuple[str, ...]]) -> set[str]:
261
+ seen: set[str] = set()
262
+ stack = list(edges[start])
263
+ while stack:
264
+ current = stack.pop()
265
+ if current in seen:
266
+ continue
267
+ seen.add(current)
268
+ stack.extend(edges[current])
269
+ return seen
270
+
271
+ def external_inputs(self) -> set[str]:
272
+ """Ref keys for inputs no instance in this graph produces."""
273
+ return set(self._external)
274
+
275
+ def select(self, target: str) -> set[str]:
276
+ """The instances needed for `target`, including all upstream dependencies.
277
+
278
+ `target` is `"*"`, a rule id (`pack/name`), or a rule id with a params filter
279
+ (`pack/name[block=timer]`); a params filter matches any instance whose params
280
+ contain those key/value pairs (a subset match). With a `TargetRegistry`, it may
281
+ also be a named target (`rtl:timer`, see `targets.py`): the union of what each
282
+ of its selectors selects.
283
+ """
284
+ if target == "*":
285
+ return set(self.instances)
286
+ if self.targets is not None and self.targets.is_target(target):
287
+ return self._select_named(target)
288
+ return self._select_rule(target)
289
+
290
+ def _select_rule(self, target: str) -> set[str]:
291
+ """`select` of a rule id with an optional params filter (never a named target)."""
292
+ rule_id, params = _parse_target(target)
293
+ if rule_id not in self.rules:
294
+ raise GraphError(f"select: unknown rule id {rule_id!r} in target {target!r}")
295
+
296
+ matched = {
297
+ iid
298
+ for iid, instance in self.instances.items()
299
+ if instance.rule_id == rule_id and params.items() <= instance.params.items()
300
+ }
301
+ if not matched:
302
+ raise GraphError(f"select: target {target!r} matched no instances")
303
+
304
+ result = set(matched)
305
+ for iid in matched:
306
+ result |= self._upstream(iid)
307
+ return result
308
+
309
+ def _select_named(self, target: str) -> set[str]:
310
+ """`select` of a named target: each selector, a required one must match.
311
+
312
+ A selector is a rule selector only: it goes to `_select_rule`, never back to a
313
+ target, so a selector that looks like one cannot recurse."""
314
+ assert self.targets is not None
315
+ try:
316
+ selectors = self.targets.resolve(target)
317
+ except ValueError as exc:
318
+ raise GraphError(str(exc)) from exc
319
+ result: set[str] = set()
320
+ for selector in selectors:
321
+ try:
322
+ result |= self._select_rule(selector.select)
323
+ except GraphError as exc:
324
+ if selector.optional:
325
+ continue
326
+ raise GraphError(
327
+ f"target {target!r}: {exc}; check its params "
328
+ f"({self.targets.describe_params(target)})"
329
+ ) from exc
330
+ return result
331
+
332
+
333
+ def build_graph(
334
+ rules: Iterable[RuleSpec],
335
+ resolver: ForeachResolver,
336
+ *,
337
+ base_params: Mapping[str, str] | None = None,
338
+ repo: str = ".",
339
+ targets: TargetRegistry | None = None,
340
+ ) -> BuildGraph:
341
+ """Build a `BuildGraph` from `rules`, expanding `foreach` with `resolver`.
342
+
343
+ An `EntryForeachResolver` may also give each instance extra inputs, sibling
344
+ instances of the same rule to run after (ordering edges), the write set its
345
+ outputs must stay inside, and placeholder values that are not params (see
346
+ `ForeachEntry`). `targets` lets `select` take named targets.
347
+
348
+ Raises `GraphError` for a missing placeholder value, two instances writing the
349
+ same output (the write-set rule, F2), an output outside an entry's write set, an
350
+ ordering edge to an instance that does not exist, or a dependency cycle.
351
+ """
352
+ rules_by_id: dict[str, RuleSpec] = {}
353
+ for rule in rules:
354
+ if rule.id in rules_by_id:
355
+ raise GraphError(f"duplicate rule id {rule.id!r}")
356
+ rules_by_id[rule.id] = rule
357
+
358
+ instances: dict[str, RuleInstance] = {}
359
+ producers: dict[str, str] = {}
360
+ spec_fragments: dict[str, str] = {}
361
+ after: dict[str, tuple[str, ...]] = {}
362
+
363
+ for rule in rules_by_id.values():
364
+ base = dict(base_params or {})
365
+ for entry in _entries(rule, resolver, base_params):
366
+ params = entry.params
367
+ values = {**entry.vars, **params}
368
+ output_refs = tuple(
369
+ ArtifactRef(
370
+ repo=repo,
371
+ kind=kind_for(path := _substitute(template, values, rule_id=rule.id)),
372
+ path=path,
373
+ )
374
+ for template in rule.outputs
375
+ )
376
+ input_refs = tuple(
377
+ _resolve_input(
378
+ input_spec, values, rule_id=rule.id, repo=repo, spec_fragments=spec_fragments
379
+ )
380
+ for input_spec in rule.inputs
381
+ )
382
+ declared = {ref.path for ref in input_refs}
383
+ input_refs += tuple(
384
+ ArtifactRef(repo=repo, kind=kind_for(path), path=path)
385
+ for path in dict.fromkeys(entry.inputs)
386
+ if path not in declared
387
+ )
388
+
389
+ instance_id = RuleInstance.make_id(rule.id, params)
390
+ if instance_id in instances:
391
+ raise GraphError(
392
+ f"duplicate instance id {instance_id!r}: 'foreach' for rule {rule.id!r} "
393
+ "produced the same params twice"
394
+ )
395
+ if entry.writes is not None:
396
+ outside = [ref.path for ref in output_refs if ref.path not in entry.writes]
397
+ if outside:
398
+ raise GraphError(
399
+ f"{instance_id!r} would write {', '.join(map(repr, outside))}, outside "
400
+ f"the write set its foreach entry grants: {sorted(entry.writes)!r}"
401
+ )
402
+ instances[instance_id] = RuleInstance(
403
+ rule_id=rule.id,
404
+ params=params,
405
+ inputs=input_refs,
406
+ outputs=output_refs,
407
+ instance_id=instance_id,
408
+ )
409
+ after[instance_id] = tuple(
410
+ RuleInstance.make_id(rule.id, {**base, **sibling}) for sibling in entry.after
411
+ )
412
+
413
+ for ref in output_refs:
414
+ key = _ref_key(ref)
415
+ if key in producers:
416
+ other = producers[key]
417
+ raise GraphError(
418
+ f"write-set conflict on {key!r}: both {other!r} and "
419
+ f"{instance_id!r} produce it"
420
+ )
421
+ producers[key] = instance_id
422
+
423
+ deps: dict[str, tuple[str, ...]] = {}
424
+ dependents: dict[str, list[str]] = {iid: [] for iid in instances}
425
+ external: set[str] = set()
426
+
427
+ for iid, instance in instances.items():
428
+ dep_ids: list[str] = []
429
+ for ref in instance.inputs:
430
+ key = _ref_key(ref)
431
+ producer = producers.get(key)
432
+ if producer is None:
433
+ external.add(key)
434
+ continue
435
+ if producer == iid or producer in dep_ids:
436
+ continue
437
+ dep_ids.append(producer)
438
+ for sibling in after[iid]:
439
+ if sibling == iid:
440
+ raise GraphError(f"{iid!r} is listed to run after itself")
441
+ if sibling not in instances:
442
+ raise GraphError(f"{iid!r} runs after {sibling!r}, which is not in the graph")
443
+ if sibling not in dep_ids:
444
+ dep_ids.append(sibling)
445
+ deps[iid] = tuple(sorted(dep_ids))
446
+ for producer in dep_ids:
447
+ dependents[producer].append(iid)
448
+
449
+ frozen_dependents = {iid: tuple(sorted(ids)) for iid, ids in dependents.items()}
450
+
451
+ _check_cycles(instances, deps)
452
+
453
+ return BuildGraph(
454
+ instances=instances,
455
+ rules=rules_by_id,
456
+ producers=producers,
457
+ deps=deps,
458
+ dependents=frozen_dependents,
459
+ external=frozenset(external),
460
+ spec_fragments=spec_fragments,
461
+ targets=targets,
462
+ )
463
+
464
+
465
+ def _resolve_input(
466
+ input_spec: InputSpec,
467
+ params: Mapping[str, str],
468
+ *,
469
+ rule_id: str,
470
+ repo: str,
471
+ spec_fragments: dict[str, str],
472
+ ) -> ArtifactRef:
473
+ """Resolve one `InputSpec` (with `params` substituted) to an `ArtifactRef`.
474
+
475
+ `model` inputs become a model-keyed ref; `spec` inputs split off the `#fragment`
476
+ (kept in `spec_fragments`, keyed by the resulting ref key, for later use); `path`
477
+ and `artifact` inputs become a path-keyed ref.
478
+ """
479
+ selector = _substitute(input_spec.selector, params, rule_id=rule_id)
480
+ if input_spec.source == "model":
481
+ return ArtifactRef(repo=repo, kind="model", model_key=selector)
482
+ if input_spec.source == "spec":
483
+ path, _, fragment = selector.partition("#")
484
+ ref = ArtifactRef(repo=repo, kind="spec", path=path)
485
+ if fragment:
486
+ spec_fragments[_ref_key(ref)] = fragment
487
+ return ref
488
+ # source in ("path", "artifact")
489
+ return ArtifactRef(repo=repo, kind=kind_for(selector), path=selector)
490
+
491
+
492
+ def _check_cycles(
493
+ instances: Mapping[str, RuleInstance], deps: Mapping[str, tuple[str, ...]]
494
+ ) -> None:
495
+ """Raise `GraphError` naming the cycle, in order, if `deps` contains one."""
496
+ WHITE, GRAY, BLACK = 0, 1, 2
497
+ color = dict.fromkeys(instances, WHITE)
498
+ stack: list[str] = []
499
+
500
+ def visit(iid: str) -> None:
501
+ color[iid] = GRAY
502
+ stack.append(iid)
503
+ for dep in deps[iid]:
504
+ if color[dep] == GRAY:
505
+ cycle_start = stack.index(dep)
506
+ cycle = [*stack[cycle_start:], dep]
507
+ raise GraphError(f"cycle detected: {' -> '.join(cycle)}")
508
+ if color[dep] == WHITE:
509
+ visit(dep)
510
+ stack.pop()
511
+ color[iid] = BLACK
512
+
513
+ for iid in sorted(instances):
514
+ if color[iid] == WHITE:
515
+ visit(iid)
516
+
517
+
518
+ # --- Staleness -------------------------------------------------------------------
519
+
520
+
521
+ class ProductionRecord(BaseModel):
522
+ """What the scheduler records when an instance finishes: its hashes at that point."""
523
+
524
+ model_config = ConfigDict(extra="forbid", frozen=True)
525
+
526
+ instance_id: str = Field(description="The instance this record is for.")
527
+ inputs_hash: Sha256 = Field(
528
+ description="hash_inputs() over this instance's input ref keys and their hashes."
529
+ )
530
+ output_hashes: dict[str, Sha256] = Field(
531
+ default_factory=dict, description="Ref key -> content hash, for each output produced."
532
+ )
533
+
534
+
535
+ StalenessState = Literal["fresh", "stale", "never_built", "diverged"]
536
+
537
+
538
+ class Staleness(BaseModel):
539
+ """The staleness state of one instance, with human-readable reasons."""
540
+
541
+ model_config = ConfigDict(extra="forbid", frozen=True)
542
+
543
+ state: StalenessState = Field(description="fresh, stale, never_built, or diverged.")
544
+ reasons: tuple[str, ...] = Field(default=(), description="Readable reasons for this state.")
545
+
546
+
547
+ _PROPAGATING_STATES = ("stale", "never_built")
548
+
549
+
550
+ def compute_staleness(
551
+ graph: BuildGraph,
552
+ records: Mapping[str, ProductionRecord],
553
+ current: Mapping[str, str],
554
+ ) -> dict[str, Staleness]:
555
+ """Compute the `Staleness` of every instance in `graph`.
556
+
557
+ `records` is what the scheduler recorded the last time each instance finished;
558
+ `current` maps ref key -> current content hash (a missing key means the file is
559
+ absent). Processed in topological order, so a dependency's staleness is known
560
+ before it is used to decide the dependent's (DESIGN 6.3: stale propagates down).
561
+ """
562
+ result: dict[str, Staleness] = {}
563
+
564
+ for iid in graph.topo_order():
565
+ instance = graph.instances[iid]
566
+ record = records.get(iid)
567
+
568
+ if record is None:
569
+ result[iid] = Staleness(state="never_built", reasons=(f"{iid} was never built",))
570
+ continue
571
+
572
+ dep_problems = [dep for dep in graph.deps(iid) if result[dep].state in _PROPAGATING_STATES]
573
+ if dep_problems:
574
+ reasons = tuple(
575
+ f"depends on {dep} which is {result[dep].state}" for dep in dep_problems
576
+ )
577
+ result[iid] = Staleness(state="stale", reasons=reasons)
578
+ continue
579
+
580
+ missing_outputs = [ref for ref in instance.outputs if _ref_key(ref) not in current]
581
+ if missing_outputs:
582
+ reasons = tuple(
583
+ f"output {ref.path or ref.model_key} missing" for ref in missing_outputs
584
+ )
585
+ result[iid] = Staleness(state="stale", reasons=reasons)
586
+ continue
587
+
588
+ inputs_hash_now = hash_inputs(
589
+ (_ref_key(ref), current.get(_ref_key(ref), "")) for ref in instance.inputs
590
+ )
591
+ if inputs_hash_now != record.inputs_hash:
592
+ reasons = tuple(_changed_input_reasons(graph, instance, records, current))
593
+ result[iid] = Staleness(state="stale", reasons=reasons or ("an input changed",))
594
+ continue
595
+
596
+ diverged_outputs = [
597
+ ref
598
+ for ref in instance.outputs
599
+ if current.get(_ref_key(ref)) != record.output_hashes.get(_ref_key(ref))
600
+ ]
601
+ if diverged_outputs and graph.rules[instance.rule_id].kind == "human":
602
+ # A person writes a `human` rule's outputs, so a changed output is that
603
+ # person's new work, not a hand edit of generated content: rebuild it.
604
+ reasons = tuple(
605
+ f"output {ref.path or ref.model_key} was edited" for ref in diverged_outputs
606
+ )
607
+ result[iid] = Staleness(state="stale", reasons=reasons)
608
+ continue
609
+ if diverged_outputs:
610
+ reasons = tuple(
611
+ f"output {ref.path or ref.model_key} changed after it was produced"
612
+ for ref in diverged_outputs
613
+ )
614
+ result[iid] = Staleness(state="diverged", reasons=reasons)
615
+ continue
616
+
617
+ result[iid] = Staleness(state="fresh", reasons=())
618
+
619
+ return result
620
+
621
+
622
+ def _changed_input_reasons(
623
+ graph: BuildGraph,
624
+ instance: RuleInstance,
625
+ records: Mapping[str, ProductionRecord],
626
+ current: Mapping[str, str],
627
+ ) -> list[str]:
628
+ """Best-effort attribution of which inputs changed, for a readable staleness reason.
629
+
630
+ An input produced by another instance in the graph can be checked against that
631
+ producer's own recorded output hash. An external input (no producer in this
632
+ graph) has no prior hash recorded anywhere reachable from here, so it is always
633
+ named when the aggregate `inputs_hash` no longer matches.
634
+ """
635
+ reasons: list[str] = []
636
+ for ref in instance.inputs:
637
+ key = _ref_key(ref)
638
+ label = ref.path or ref.model_key or key
639
+ producer = graph.producers.get(key)
640
+ if producer is not None:
641
+ producer_record = records.get(producer)
642
+ if producer_record is not None and producer_record.output_hashes.get(
643
+ key
644
+ ) == current.get(key):
645
+ continue
646
+ reasons.append(f"input {label} changed")
647
+ return reasons