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,693 @@
1
+ """Deterministically merge per-source models into one Design Model (DESIGN.md 4.1-4.5).
2
+
3
+ `ingest` is the pure core of `chipgraph ingest`: it takes the models that the format
4
+ adapters and extractors already built, each tagged with where it came from
5
+ (`SourcePart`), and merges them into a single `DesignModel`, a `build_inputs_hash` and a
6
+ JSON-dumpable `IngestStats`. It never touches disk, never calls a tool, and names no
7
+ chip, bus, PDK or tool: the app layer (`chipgraph.app.ingest`) does all of that and hands
8
+ the results here.
9
+
10
+ The merge is deterministic (independent of the order the parts arrive) and never raises on
11
+ content: two parts that disagree on an entity's content produce one kept entity plus an
12
+ `IngestIssue(code="conflict")`, rather than a `ModelConflict`. Ownership of a module that
13
+ several blocks elaborate (vendor cells, a `top.f` that pulls in every block) is decided by
14
+ a fixed rule, so the same inputs always give the same owner.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from collections.abc import Callable, Iterable, Mapping, Sequence
21
+ from dataclasses import dataclass, field
22
+ from typing import Literal
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field
25
+
26
+ from chipgraph.core.model.entities import BlockEntity, EntityBase, InterruptEntity, ModuleEntity
27
+ from chipgraph.core.model.json_value import JSONValue
28
+ from chipgraph.core.model.model import DesignModel
29
+ from chipgraph.core.model.relations import Relation
30
+
31
+ Severity = Literal["error", "warning", "info"]
32
+
33
+ # Source priority for tie-breaking a content conflict: a chip-level spec beats a MAS,
34
+ # which beats extracted RTL. Anything unlisted sorts last. This is a role, not a concrete
35
+ # format or tool name, so core stays chip/tool-agnostic.
36
+ _SOURCE_PRIORITY: dict[str, int] = {"chip": 0, "mas": 1, "rtl": 2}
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class SourcePart:
41
+ """One already-built model, tagged with where it came from.
42
+
43
+ `source` is a coarse role used only for conflict tie-breaking: `"chip"` (the
44
+ chip-level spec), `"mas"` (a per-block spec) or `"rtl"` (extracted RTL). `block` is
45
+ the block this part belongs to, or None for a part that spans blocks (a chip-level
46
+ spec, or RTL elaborated with no single owning block). `diagnostics` are issues the
47
+ adapter/extractor already found; ingest passes them through unchanged.
48
+ """
49
+
50
+ source: str
51
+ block: str | None
52
+ model: DesignModel
53
+ diagnostics: tuple[IngestIssue, ...] = ()
54
+
55
+
56
+ class IngestIssue(BaseModel):
57
+ """One problem found while ingesting, JSON-dumpable and never raised."""
58
+
59
+ model_config = ConfigDict(extra="forbid", frozen=True)
60
+
61
+ severity: Severity = Field(description="How serious the issue is.")
62
+ code: str = Field(description="A short machine code, e.g. 'conflict', 'dangling'.")
63
+ message: str = Field(description="A human-readable description.")
64
+ key: str | None = Field(default=None, description="Model key the issue is about, if any.")
65
+ block: str | None = Field(default=None, description="Block the issue is about, if any.")
66
+ file: str | None = Field(default=None, description="Source file, repo-relative POSIX.")
67
+ line: int | None = Field(default=None, description="1-based source line, if any.")
68
+ sources: tuple[str, ...] = Field(
69
+ default=(), description="The source roles involved (e.g. for a conflict)."
70
+ )
71
+
72
+
73
+ class IngestStats(BaseModel):
74
+ """Deterministic, JSON-dumpable counts describing an ingested model."""
75
+
76
+ model_config = ConfigDict(extra="forbid", frozen=True)
77
+
78
+ inputs: int = Field(default=0, description="Number of input files read.")
79
+ entities: int = Field(default=0, description="Total entities in the merged model.")
80
+ relations: int = Field(default=0, description="Total relations in the merged model.")
81
+ entities_by_kind: dict[str, int] = Field(
82
+ default_factory=dict, description="Entity count per kind."
83
+ )
84
+ entities_by_source: dict[str, int] = Field(
85
+ default_factory=dict, description="Entity count per source role."
86
+ )
87
+ entities_by_block: dict[str, int] = Field(
88
+ default_factory=dict, description="Entity count per owning block key ('' = none)."
89
+ )
90
+ relations_by_kind: dict[str, int] = Field(
91
+ default_factory=dict, description="Relation count per kind."
92
+ )
93
+ diagnostics_by_severity: dict[str, int] = Field(
94
+ default_factory=dict, description="Issue count per severity."
95
+ )
96
+ conflicts: int = Field(default=0, description="Number of content conflicts recorded.")
97
+
98
+
99
+ class IngestResult(BaseModel):
100
+ """The outcome of `ingest`: the model is returned separately (it is not a pydantic model)."""
101
+
102
+ model_config = ConfigDict(extra="forbid", frozen=True)
103
+
104
+ stats: IngestStats
105
+ issues: tuple[IngestIssue, ...]
106
+ build_inputs_hash: str
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class InputFile:
111
+ """One input file that was read, for the build-inputs hash."""
112
+
113
+ rel_path: str
114
+ sha256: str
115
+
116
+
117
+ @dataclass(slots=True)
118
+ class _MergeState:
119
+ entities: dict[str, EntityBase] = field(default_factory=dict)
120
+ entity_source: dict[str, str] = field(default_factory=dict)
121
+ entity_owner_first: dict[str, bool] = field(default_factory=dict)
122
+ entity_origin: dict[str, str] = field(default_factory=dict)
123
+ relations: list[Relation] = field(default_factory=list)
124
+ issues: list[IngestIssue] = field(default_factory=list)
125
+ conflicts: int = 0
126
+
127
+
128
+ def ingest(
129
+ parts: Iterable[SourcePart],
130
+ *,
131
+ input_files: Sequence[InputFile] = (),
132
+ profile_digest: str | None = None,
133
+ ip_blocks: Mapping[str, tuple[str, ...]] | None = None,
134
+ ) -> tuple[DesignModel, IngestResult]:
135
+ """Merge `parts` into one model, plus stats, issues and a build-inputs hash.
136
+
137
+ Deterministic: the returned model, stats and issues do not depend on the order of
138
+ `parts`, and the module-ownership rule breaks every tie the same way each time. Never
139
+ raises on conflicting content: a content conflict is kept-one-and-recorded.
140
+
141
+ `ip_blocks` maps an IP block name to the names of its declared memory-map instances
142
+ (D38). When given, ingest ensures a `block:<ip>` entity exists (with `attrs.role =
143
+ "ip"` if it had to create it), links each existing instance to its IP with an
144
+ `instance_of` relation, and reports instances/blocks it cannot map as warnings. Passing
145
+ `None` leaves behaviour unchanged.
146
+ """
147
+ ordered = _order_parts(parts)
148
+ owners = _assign_ownership(ordered)
149
+
150
+ state = _MergeState()
151
+ for part in ordered:
152
+ _merge_part(state, part, owners)
153
+
154
+ _add_ownership_relations(state, owners)
155
+ if ip_blocks is not None:
156
+ _add_instance_relations(state, ip_blocks)
157
+ model = DesignModel(entities=dict(state.entities), relations=tuple(state.relations))
158
+
159
+ for message in model.validate_relations():
160
+ state.issues.append(IngestIssue(severity="warning", code="dangling", message=message))
161
+
162
+ issues = _sorted_issues(state.issues + [d for part in ordered for d in part.diagnostics])
163
+ stats = _compute_stats(model, state, owners, len(input_files), issues)
164
+ build_inputs_hash = _build_inputs_hash(input_files, profile_digest)
165
+ return model, IngestResult(stats=stats, issues=issues, build_inputs_hash=build_inputs_hash)
166
+
167
+
168
+ def _order_parts(parts: Iterable[SourcePart]) -> list[SourcePart]:
169
+ """A deterministic, order-independent processing order for the parts.
170
+
171
+ Sorted by (source priority, block key, a stable content digest), so shuffling the
172
+ input never changes the merged result.
173
+ """
174
+ return sorted(parts, key=_part_sort_key)
175
+
176
+
177
+ def _part_sort_key(part: SourcePart) -> tuple[int, str, str, str]:
178
+ priority = _SOURCE_PRIORITY.get(part.source, len(_SOURCE_PRIORITY))
179
+ return (priority, part.source, part.block or "", _part_digest(part))
180
+
181
+
182
+ def _part_digest(part: SourcePart) -> str:
183
+ hasher = hashlib.sha256()
184
+ hasher.update(part.source.encode("utf-8"))
185
+ hasher.update(b"\x00")
186
+ hasher.update((part.block or "").encode("utf-8"))
187
+ hasher.update(b"\x00")
188
+ for key in sorted(part.model.entities):
189
+ hasher.update(key.encode("utf-8"))
190
+ hasher.update(b"\x00")
191
+ return hasher.hexdigest()
192
+
193
+
194
+ # --- module ownership --------------------------------------------------------------
195
+
196
+
197
+ def _assign_ownership(parts: Sequence[SourcePart]) -> dict[str, _Ownership]:
198
+ """Decide which block owns each module key that appears in a block-tagged part.
199
+
200
+ A module is a candidate for every block whose part contains it. With one candidate,
201
+ that block owns it. With several, ownership goes to (in order): the block whose
202
+ filelist directory contains the module's own file; else the block whose name matches
203
+ the module (a filelist top); else the block whose part has the fewest modules, if that
204
+ block is unique; else no owner. `attrs.used_by` records all candidates when more than
205
+ one.
206
+ """
207
+ module_files: dict[str, str | None] = {}
208
+ candidates: dict[str, list[str]] = {}
209
+ module_counts: dict[str, int] = {}
210
+
211
+ for part in parts:
212
+ if part.block is None:
213
+ continue
214
+ block_modules = [e for e in part.model.entities.values() if isinstance(e, ModuleEntity)]
215
+ module_counts[part.block] = len(block_modules)
216
+ for module in block_modules:
217
+ candidates.setdefault(module.key, [])
218
+ if part.block not in candidates[module.key]:
219
+ candidates[module.key].append(part.block)
220
+ if module.key not in module_files or module_files[module.key] is None:
221
+ module_files[module.key] = module.file
222
+
223
+ owners: dict[str, _Ownership] = {}
224
+ for module_key, cand in candidates.items():
225
+ cand_sorted = sorted(cand)
226
+ owner = _pick_owner(module_key, cand_sorted, module_files.get(module_key), module_counts)
227
+ used_by = tuple(cand_sorted) if len(cand_sorted) > 1 else ()
228
+ owners[module_key] = _Ownership(owner=owner, used_by=used_by)
229
+ return owners
230
+
231
+
232
+ def _pick_owner(
233
+ module_key: str,
234
+ candidates: Sequence[str],
235
+ module_file: str | None,
236
+ module_counts: Mapping[str, int],
237
+ ) -> str | None:
238
+ if len(candidates) == 1:
239
+ return candidates[0]
240
+ if not candidates:
241
+ return None
242
+
243
+ # 1. The block whose filelist directory contains the module's file. A block key is
244
+ # "block:<name>"; the directory heuristic matches "/<name>/" in the file path.
245
+ if module_file is not None:
246
+ dir_matches = [b for b in candidates if _block_dir_matches(b, module_file)]
247
+ if len(dir_matches) == 1:
248
+ return dir_matches[0]
249
+
250
+ # 2. The block whose name matches the module (its filelist's own top).
251
+ name_matches = [b for b in candidates if _block_name_matches(b, module_key)]
252
+ if len(name_matches) == 1:
253
+ return name_matches[0]
254
+
255
+ # 3. The block with the fewest modules, if that block is unique.
256
+ fewest = min(module_counts.get(b, 0) for b in candidates)
257
+ smallest = [b for b in candidates if module_counts.get(b, 0) == fewest]
258
+ if len(smallest) == 1:
259
+ return smallest[0]
260
+
261
+ return None
262
+
263
+
264
+ def _block_name(block_key: str) -> str:
265
+ _, _, rest = block_key.partition(":")
266
+ return rest
267
+
268
+
269
+ def _block_dir_matches(block_key: str, module_file: str) -> bool:
270
+ name = _block_name(block_key)
271
+ if not name:
272
+ return False
273
+ return f"/{name}/" in f"/{module_file}"
274
+
275
+
276
+ def _block_name_matches(block_key: str, module_key: str) -> bool:
277
+ _, _, module_name = module_key.partition(":")
278
+ name = _block_name(block_key)
279
+ if not name or not module_name:
280
+ return False
281
+ # The module is the block's own top when its name is or ends with the block name.
282
+ return module_name == name or module_name.endswith(f"_{name}") or name in module_name.split("_")
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class _Ownership:
287
+ owner: str | None
288
+ used_by: tuple[str, ...]
289
+
290
+
291
+ # --- merging -----------------------------------------------------------------------
292
+
293
+
294
+ def _merge_part(state: _MergeState, part: SourcePart, owners: Mapping[str, _Ownership]) -> None:
295
+ is_owner_of = _owner_predicate(part, owners)
296
+ for key, entity in part.model.entities.items():
297
+ entity = _apply_ownership(entity, owners)
298
+ origin = f"{part.source}:{part.block}" if part.block else part.source
299
+ owner = is_owner_of(_owning_module(key, entity))
300
+ _merge_entity(state, key, entity, part.source, owner, origin)
301
+ state.relations.extend(part.model.relations)
302
+
303
+
304
+ def _owning_module(key: str, entity: EntityBase) -> str:
305
+ """The module key whose ownership decides a conflict on `entity`.
306
+
307
+ A module decides for itself; a port or parameter decides by the module it belongs to,
308
+ so the owning block's elaboration of a shared module wins for its ports and parameters
309
+ too. Anything else decides by its own key.
310
+ """
311
+ module = getattr(entity, "module", None)
312
+ if isinstance(module, str) and module:
313
+ return module
314
+ return key
315
+
316
+
317
+ def _owner_predicate(part: SourcePart, owners: Mapping[str, _Ownership]) -> Callable[[str], bool]:
318
+ def predicate(key: str) -> bool:
319
+ ownership = owners.get(key)
320
+ if ownership is None or ownership.owner is None:
321
+ return part.block is None
322
+ return ownership.owner == part.block
323
+
324
+ return predicate
325
+
326
+
327
+ def _apply_ownership(entity: EntityBase, owners: Mapping[str, _Ownership]) -> EntityBase:
328
+ if not isinstance(entity, ModuleEntity):
329
+ return entity
330
+ ownership = owners.get(entity.key)
331
+ if ownership is None:
332
+ return entity
333
+ updates: dict[str, object] = {}
334
+ if ownership.owner is not None:
335
+ updates["block"] = ownership.owner
336
+ if ownership.used_by:
337
+ attrs: dict[str, JSONValue] = dict(entity.attrs)
338
+ attrs["used_by"] = list(ownership.used_by)
339
+ updates["attrs"] = attrs
340
+ if not updates:
341
+ return entity
342
+ return entity.model_copy(update=updates)
343
+
344
+
345
+ def _merge_entity(
346
+ state: _MergeState,
347
+ key: str,
348
+ entity: EntityBase,
349
+ source: str,
350
+ is_owner: bool,
351
+ origin: str,
352
+ ) -> None:
353
+ existing = state.entities.get(key)
354
+ if existing is None:
355
+ state.entities[key] = entity
356
+ state.entity_source[key] = source
357
+ state.entity_owner_first[key] = is_owner
358
+ state.entity_origin[key] = origin
359
+ return
360
+ if existing == entity:
361
+ return
362
+
363
+ # A content conflict: keep one by a fixed rule and record it.
364
+ keep_existing = _keeps_existing(
365
+ state.entity_owner_first[key], state.entity_source[key], is_owner, source, existing, entity
366
+ )
367
+ kept = existing if keep_existing else entity
368
+ old_origin = state.entity_origin[key]
369
+ kept_origin, dropped_origin = (old_origin, origin) if keep_existing else (origin, old_origin)
370
+ if not keep_existing:
371
+ state.entities[key] = entity
372
+ state.entity_source[key] = source
373
+ state.entity_owner_first[key] = is_owner
374
+ state.entity_origin[key] = origin
375
+ state.conflicts += 1
376
+ state.issues.append(
377
+ IngestIssue(
378
+ severity="warning",
379
+ code="conflict",
380
+ key=key,
381
+ message=(
382
+ f"conflicting content for {key!r}; kept {kept_origin} "
383
+ f"({kept.source.file or '?'}:{kept.source.line or '-'}), dropped {dropped_origin}"
384
+ ),
385
+ file=kept.source.file,
386
+ line=kept.source.line,
387
+ sources=tuple(sorted({state.entity_source[key], source})),
388
+ )
389
+ )
390
+
391
+
392
+ def _keeps_existing(
393
+ existing_owner: bool,
394
+ existing_source: str,
395
+ new_owner: bool,
396
+ new_source: str,
397
+ existing: EntityBase,
398
+ new: EntityBase,
399
+ ) -> bool:
400
+ """The fixed conflict rule: owner's part first, then source priority, then key order.
401
+
402
+ Returns True to keep the already-stored entity, False to replace it with `new`.
403
+ """
404
+ if existing_owner != new_owner:
405
+ return existing_owner
406
+ existing_priority = _SOURCE_PRIORITY.get(existing_source, len(_SOURCE_PRIORITY))
407
+ new_priority = _SOURCE_PRIORITY.get(new_source, len(_SOURCE_PRIORITY))
408
+ if existing_priority != new_priority:
409
+ return existing_priority <= new_priority
410
+ # A final deterministic tie-break on the entity's own dumped content.
411
+ return _content_key(existing) <= _content_key(new)
412
+
413
+
414
+ def _content_key(entity: EntityBase) -> str:
415
+ return repr(entity.model_dump(mode="json"))
416
+
417
+
418
+ def _add_ownership_relations(state: _MergeState, owners: Mapping[str, _Ownership]) -> None:
419
+ """Add a `contains` relation from an owner block to each module it owns.
420
+
421
+ Only added when both the block and the module are present in the merged model, and
422
+ only once per (block, module) pair.
423
+ """
424
+ seen: set[tuple[str, str]] = {(r.src, r.dst) for r in state.relations if r.kind == "contains"}
425
+ for module_key, ownership in sorted(owners.items()):
426
+ if ownership.owner is None:
427
+ continue
428
+ if ownership.owner not in state.entities or module_key not in state.entities:
429
+ continue
430
+ pair = (ownership.owner, module_key)
431
+ if pair in seen:
432
+ continue
433
+ seen.add(pair)
434
+ state.relations.append(Relation(kind="contains", src=ownership.owner, dst=module_key))
435
+
436
+
437
+ # --- instances (D38) ---------------------------------------------------------------
438
+
439
+
440
+ def _block_key(name: str) -> str:
441
+ return f"block:{name}"
442
+
443
+
444
+ def _add_instance_relations(state: _MergeState, ip_blocks: Mapping[str, tuple[str, ...]]) -> None:
445
+ """Ensure IP blocks exist and link each memory-map instance to its IP (D38).
446
+
447
+ For each IP its instance set is the declared instances, or `(ip,)` when none is
448
+ declared (the same-name rule). A `block:<ip>` entity is created (with `attrs.role =
449
+ "ip"`) when missing. An `instance_of` relation is added for every instance that exists
450
+ in the merged model and differs from the IP. Instances/blocks that cannot be mapped
451
+ are reported as deterministic warnings.
452
+ """
453
+ interrupt_names = _assign_interrupt_blocks(state, ip_blocks)
454
+ instance_to_ip: dict[str, str] = {}
455
+ for ip in sorted(ip_blocks):
456
+ declared = ip_blocks[ip]
457
+ instances = declared if declared else (ip,)
458
+
459
+ ip_key = _block_key(ip)
460
+ if ip_key not in state.entities:
461
+ state.entities[ip_key] = BlockEntity(key=ip_key, name=ip, attrs={"role": "ip"})
462
+
463
+ for instance in instances:
464
+ instance_key = _block_key(instance)
465
+ if instance_key not in state.entities:
466
+ # A declared instance with no matching block in the model, unless it names
467
+ # an interrupt source the contract lists without a block (mapped above).
468
+ if instance != ip and instance not in interrupt_names:
469
+ state.issues.append(
470
+ IngestIssue(
471
+ severity="warning",
472
+ code="instance_unknown",
473
+ message=(
474
+ f"block {ip!r} declares instance {instance!r}, but there is "
475
+ f"no {instance_key!r} in the model"
476
+ ),
477
+ key=instance_key,
478
+ block=ip_key,
479
+ )
480
+ )
481
+ continue
482
+ instance_to_ip[instance] = ip
483
+ if instance == ip:
484
+ # Same-name rule: the IP is its own instance; do not relate it to itself.
485
+ continue
486
+ state.relations.append(Relation(kind="instance_of", src=instance_key, dst=ip_key))
487
+
488
+ _report_unmapped_instances(state, ip_blocks, instance_to_ip)
489
+ _report_unknown_blocks(state)
490
+
491
+
492
+ def _assign_interrupt_blocks(
493
+ state: _MergeState, ip_blocks: Mapping[str, tuple[str, ...]]
494
+ ) -> set[str]:
495
+ """Give a block to each interrupt the chip spec lists without one (D38).
496
+
497
+ A contract may name an interrupt by its peripheral (`dma`, `spi_host`, `wdt_wakeup`)
498
+ rather than by a memory-map block. Such an interrupt belongs to the IP whose name is
499
+ that peripheral, or whose declared `instances` list it; nothing is guessed from the
500
+ name otherwise. The target is `block:<name>` when that block exists, else the IP's
501
+ block. The interrupt records `attrs.block_from = "profile"`. Returns the names that
502
+ matched, so they are not also reported as unknown instances.
503
+ """
504
+ owner_of: dict[str, str] = {}
505
+ for ip in sorted(ip_blocks):
506
+ owner_of.setdefault(ip, ip)
507
+ for name in ip_blocks[ip]:
508
+ owner_of.setdefault(name, ip)
509
+
510
+ matched: set[str] = set()
511
+ for key in sorted(state.entities):
512
+ entity = state.entities[key]
513
+ if not isinstance(entity, InterruptEntity) or entity.block:
514
+ continue
515
+ peripheral = entity.attrs.get("peripheral")
516
+ name = peripheral if isinstance(peripheral, str) and peripheral else _block_name(key)
517
+ owner = owner_of.get(name)
518
+ if owner is None:
519
+ continue
520
+ target = _block_key(name) if _block_key(name) in state.entities else _block_key(owner)
521
+ attrs: dict[str, JSONValue] = dict(entity.attrs)
522
+ attrs["block_from"] = "profile"
523
+ state.entities[key] = entity.model_copy(update={"block": target, "attrs": attrs})
524
+ matched.add(name)
525
+ return matched
526
+
527
+
528
+ def _report_unmapped_instances(
529
+ state: _MergeState,
530
+ ip_blocks: Mapping[str, tuple[str, ...]],
531
+ instance_to_ip: Mapping[str, str],
532
+ ) -> None:
533
+ """Warn about every block in the model that is neither an IP nor an instance of one."""
534
+ ips = set(ip_blocks)
535
+ mapped = set(instance_to_ip)
536
+ for key in sorted(state.entities):
537
+ entity = state.entities[key]
538
+ if not isinstance(entity, BlockEntity):
539
+ continue
540
+ name = _block_name(key)
541
+ if name in ips or name in mapped:
542
+ continue
543
+ state.issues.append(
544
+ IngestIssue(
545
+ severity="warning",
546
+ code="instance_unmapped",
547
+ message=(
548
+ f"block {key!r} is neither a declared IP nor an instance of one; "
549
+ "declare it under blocks.<ip>.instances"
550
+ ),
551
+ key=key,
552
+ )
553
+ )
554
+
555
+
556
+ def _report_unknown_blocks(state: _MergeState) -> None:
557
+ """Warn once per `block:<x>` referenced but absent from the model.
558
+
559
+ Checks the typed `block` field of modules, registers, interrupts and memory regions,
560
+ an `attrs.block` on any entity, and every relation endpoint. Each distinct missing
561
+ block is reported once, with a count and one example key.
562
+ """
563
+ referrers: dict[str, list[str]] = {}
564
+
565
+ def record(block_ref: str, referrer: str) -> None:
566
+ if not block_ref.startswith("block:"):
567
+ return
568
+ if block_ref in state.entities:
569
+ return
570
+ referrers.setdefault(block_ref, []).append(referrer)
571
+
572
+ for key in sorted(state.entities):
573
+ entity = state.entities[key]
574
+ typed_block = getattr(entity, "block", None)
575
+ if isinstance(typed_block, str) and typed_block:
576
+ record(typed_block, key)
577
+ attr_block = entity.attrs.get("block")
578
+ if isinstance(attr_block, str) and attr_block:
579
+ record(attr_block, key)
580
+
581
+ for index, relation in enumerate(state.relations):
582
+ for endpoint in (relation.src, relation.dst):
583
+ record(endpoint, f"relation[{index}]:{relation.kind}")
584
+
585
+ for block_ref in sorted(referrers):
586
+ examples = sorted(referrers[block_ref])
587
+ state.issues.append(
588
+ IngestIssue(
589
+ severity="warning",
590
+ code="block_unknown",
591
+ message=(
592
+ f"{block_ref!r} is referenced by {len(examples)} entity/relation(s) "
593
+ f"but is not in the model (e.g. {examples[0]!r})"
594
+ ),
595
+ key=block_ref,
596
+ )
597
+ )
598
+
599
+
600
+ # --- stats and hashing -------------------------------------------------------------
601
+
602
+
603
+ def _compute_stats(
604
+ model: DesignModel,
605
+ state: _MergeState,
606
+ owners: Mapping[str, _Ownership],
607
+ input_count: int,
608
+ issues: Sequence[IngestIssue],
609
+ ) -> IngestStats:
610
+ by_kind: dict[str, int] = {}
611
+ by_source: dict[str, int] = {}
612
+ by_block: dict[str, int] = {}
613
+ for key, entity in model.entities.items():
614
+ by_kind[entity.kind] = by_kind.get(entity.kind, 0) + 1
615
+ source = state.entity_source.get(key, "")
616
+ by_source[source] = by_source.get(source, 0) + 1
617
+ block = _entity_block(entity)
618
+ by_block[block] = by_block.get(block, 0) + 1
619
+
620
+ by_relation_kind: dict[str, int] = {}
621
+ for relation in model.relations:
622
+ by_relation_kind[relation.kind] = by_relation_kind.get(relation.kind, 0) + 1
623
+
624
+ by_severity: dict[str, int] = {}
625
+ for issue in issues:
626
+ by_severity[issue.severity] = by_severity.get(issue.severity, 0) + 1
627
+
628
+ return IngestStats(
629
+ inputs=input_count,
630
+ entities=len(model.entities),
631
+ relations=len(model.relations),
632
+ entities_by_kind=dict(sorted(by_kind.items())),
633
+ entities_by_source=dict(sorted(by_source.items())),
634
+ entities_by_block=dict(sorted(by_block.items())),
635
+ relations_by_kind=dict(sorted(by_relation_kind.items())),
636
+ diagnostics_by_severity=dict(sorted(by_severity.items())),
637
+ conflicts=state.conflicts,
638
+ )
639
+
640
+
641
+ def _entity_block(entity: EntityBase) -> str:
642
+ block = getattr(entity, "block", None)
643
+ if isinstance(block, str):
644
+ return block
645
+ attr_block = entity.attrs.get("block")
646
+ if isinstance(attr_block, str):
647
+ return attr_block
648
+ return ""
649
+
650
+
651
+ def _sorted_issues(issues: Iterable[IngestIssue]) -> tuple[IngestIssue, ...]:
652
+ _severity_order = {"error": 0, "warning": 1, "info": 2}
653
+ return tuple(
654
+ sorted(
655
+ issues,
656
+ key=lambda i: (
657
+ _severity_order.get(i.severity, 3),
658
+ i.code,
659
+ i.key or "",
660
+ i.file or "",
661
+ i.line or 0,
662
+ i.message,
663
+ ),
664
+ )
665
+ )
666
+
667
+
668
+ def _build_inputs_hash(input_files: Sequence[InputFile], profile_digest: str | None) -> str:
669
+ """sha256 over sorted (relative path, file sha256) plus the profile digest.
670
+
671
+ Changing any listed input's content, or an ingest-relevant profile section, changes
672
+ the hash; reordering the inputs does not.
673
+ """
674
+ hasher = hashlib.sha256()
675
+ for input_file in sorted(input_files, key=lambda f: f.rel_path):
676
+ hasher.update(input_file.rel_path.encode("utf-8"))
677
+ hasher.update(b"\x00")
678
+ hasher.update(input_file.sha256.encode("utf-8"))
679
+ hasher.update(b"\x00")
680
+ if profile_digest is not None:
681
+ hasher.update(b"profile\x00")
682
+ hasher.update(profile_digest.encode("utf-8"))
683
+ return hasher.hexdigest()
684
+
685
+
686
+ __all__ = [
687
+ "IngestIssue",
688
+ "IngestResult",
689
+ "IngestStats",
690
+ "InputFile",
691
+ "SourcePart",
692
+ "ingest",
693
+ ]
@@ -0,0 +1,4 @@
1
+ """A recursive JSON-value type alias shared by the Design Model's ``attrs`` fields."""
2
+
3
+ type JSONValue = bool | int | float | str | list[JSONValue] | dict[str, JSONValue] | None
4
+ """Any value that round-trips through JSON: used for open-ended entity/relation ``attrs``."""