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,179 @@
1
+ """`LlmDecideBackend`: `decide()`'s model tiers over an `LlmProvider` (API runtime).
2
+
3
+ Each question becomes one request, to the model the profile's `models.tiers` names for
4
+ the tier, asking for strict JSON::
5
+
6
+ {"value": "<one of the choices>", "confidence": 0.0-1.0, "reason": "<one sentence>"}
7
+
8
+ The reply is read tolerantly (`parse_answer`): the JSON may be alone, in a fenced code
9
+ block, or inside other text; anything unreadable is an answer with no value and
10
+ confidence 0, which `decide()` escalates.
11
+
12
+ Every call goes through a `GuardedProvider` (a bare provider is wrapped in one), so the
13
+ 'nda' rule, the run budget and retry apply: a question labeled 'nda' is refused before
14
+ any call unless the provider is local.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import re
22
+ from dataclasses import dataclass
23
+
24
+ from chipgraph.adapters.llm._errors import ProviderConfigError
25
+ from chipgraph.adapters.llm.guard import GuardedProvider
26
+ from chipgraph.core.config.models import DataCfg, DecideTier, ModelsCfg
27
+ from chipgraph.core.engine.decide import ModelAnswer, Question
28
+ from chipgraph.core.plugin_api.protocols import LlmProvider
29
+ from chipgraph.core.plugin_api.types import LlmMessage, LlmRequest
30
+
31
+ DEFAULT_MAX_TOKENS = 300
32
+ """Output tokens allowed for one answer: a short JSON object."""
33
+
34
+ SYSTEM_PROMPT = """\
35
+ You answer one multiple-choice question for a chip design tool.
36
+ Reply with a single JSON object and nothing else:
37
+ {"value": "<exactly one of the choices>", "confidence": <number from 0 to 1>, \
38
+ "reason": "<one short sentence>"}
39
+ - "value" must be copied exactly from the choices.
40
+ - "confidence" is how sure you are that "value" is right: 1 is certain, 0.5 is a guess.
41
+ - Use only the question and its context; if they do not tell, pick the most likely
42
+ choice and give a low confidence."""
43
+ """The system message of every decide request."""
44
+
45
+ _FENCE = re.compile(r"```(?:json|JSON)?\s*\n?(.*?)```", re.DOTALL)
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class ParsedAnswer:
50
+ """A model reply as read by `parse_answer`: `value` None when it had none."""
51
+
52
+ value: str | None
53
+ confidence: float
54
+ reason: str
55
+
56
+
57
+ def question_prompt(question: Question) -> str:
58
+ """The user message for `question`: the question, its choices, and its context."""
59
+ choices = "\n".join(f"- {json.dumps(c, ensure_ascii=False)}" for c in question.choices)
60
+ parts = [f"Question: {question.prompt}", f"Choices:\n{choices}"]
61
+ if question.context:
62
+ parts.append(f"Context:\n<<<\n{question.context}\n>>>")
63
+ parts.append("Answer with the JSON object only.")
64
+ return "\n\n".join(parts)
65
+
66
+
67
+ def _objects(text: str) -> list[object]:
68
+ """JSON values found in `text`: the whole text, fenced blocks, then each `{...}`."""
69
+ found: list[object] = []
70
+ candidates = [text.strip(), *(m.group(1).strip() for m in _FENCE.finditer(text))]
71
+ for candidate in candidates:
72
+ try:
73
+ found.append(json.loads(candidate))
74
+ except ValueError:
75
+ continue
76
+ decoder = json.JSONDecoder()
77
+ for match in re.finditer(r"\{", text):
78
+ try:
79
+ value, _ = decoder.raw_decode(text, match.start())
80
+ except ValueError:
81
+ continue
82
+ found.append(value)
83
+ return found
84
+
85
+
86
+ def _confidence(raw: object) -> float | None:
87
+ if isinstance(raw, bool) or not isinstance(raw, int | float):
88
+ return None
89
+ value = float(raw)
90
+ if not math.isfinite(value) or not 0 <= value <= 1:
91
+ return None
92
+ return value
93
+
94
+
95
+ def parse_answer(text: str) -> ParsedAnswer:
96
+ """Read `{"value", "confidence", "reason"}` from a model reply, tolerantly.
97
+
98
+ The first JSON object with a string `value` wins. A missing or out-of-range
99
+ `confidence` makes it 0; a reply with no such object is `value=None`, confidence 0.
100
+ """
101
+ for obj in _objects(text):
102
+ if not isinstance(obj, dict) or not isinstance(obj.get("value"), str):
103
+ continue
104
+ confidence = _confidence(obj.get("confidence"))
105
+ reason = obj.get("reason")
106
+ return ParsedAnswer(
107
+ value=obj["value"],
108
+ confidence=confidence if confidence is not None else 0.0,
109
+ reason=reason if isinstance(reason, str) else "",
110
+ )
111
+ return ParsedAnswer(value=None, confidence=0.0, reason="")
112
+
113
+
114
+ class LlmDecideBackend:
115
+ """A `ModelBackend` for `decide()` that asks an LLM provider for structured JSON.
116
+
117
+ `models.tiers[tier]` names the model per tier (`small`, `large`); a tier with no
118
+ model is a `ProviderConfigError`. `provider` is wrapped in a `GuardedProvider`
119
+ (with `data`, the profile's `data` section) unless it already is one.
120
+ """
121
+
122
+ name = "llm"
123
+
124
+ def __init__(
125
+ self,
126
+ provider: LlmProvider,
127
+ models: ModelsCfg,
128
+ *,
129
+ data: DataCfg | None = None,
130
+ max_tokens: int = DEFAULT_MAX_TOKENS,
131
+ ) -> None:
132
+ self.provider = (
133
+ provider
134
+ if isinstance(provider, GuardedProvider)
135
+ else GuardedProvider(provider, data=data)
136
+ )
137
+ self.models = models
138
+ self.max_tokens = max_tokens
139
+
140
+ def model_for(self, tier: DecideTier) -> str:
141
+ """The model id for `tier`, from `models.tiers`."""
142
+ model = self.models.tiers.get(tier)
143
+ if not model:
144
+ raise ProviderConfigError(
145
+ f"models.tiers.{tier} is not set: decide() needs a model for the {tier!r} tier"
146
+ )
147
+ return model
148
+
149
+ def request(self, question: Question, tier: DecideTier) -> LlmRequest:
150
+ """The request `ask` sends for `question` to `tier`."""
151
+ return LlmRequest(
152
+ model=self.model_for(tier),
153
+ messages=(
154
+ LlmMessage(role="system", content=SYSTEM_PROMPT),
155
+ LlmMessage(role="user", content=question_prompt(question)),
156
+ ),
157
+ max_tokens=self.max_tokens,
158
+ labels=question.labels,
159
+ )
160
+
161
+ async def ask(self, question: Question, tier: DecideTier) -> ModelAnswer:
162
+ response = await self.provider.complete(self.request(question, tier))
163
+ parsed = parse_answer(response.text)
164
+ return ModelAnswer(
165
+ value=parsed.value,
166
+ confidence=parsed.confidence,
167
+ model=response.model,
168
+ reason=parsed.reason,
169
+ )
170
+
171
+
172
+ __all__ = [
173
+ "DEFAULT_MAX_TOKENS",
174
+ "SYSTEM_PROMPT",
175
+ "LlmDecideBackend",
176
+ "ParsedAnswer",
177
+ "parse_answer",
178
+ "question_prompt",
179
+ ]
@@ -0,0 +1,87 @@
1
+ """`FakeProvider`: a deterministic, scripted LLM provider for tests and CI (no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Callable, Iterable
7
+
8
+ from chipgraph.adapters.llm._errors import LlmError, ProviderError
9
+ from chipgraph.adapters.llm._nda import ensure_nda_allowed
10
+ from chipgraph.core.plugin_api.types import LlmRequest, LlmResponse
11
+
12
+ Script = Iterable[str] | Callable[[LlmRequest], str]
13
+ """Either a queue of reply texts, used in order, or a function of the request."""
14
+
15
+
16
+ def count_tokens(text: str) -> int:
17
+ """The fake's token count: the number of whitespace-separated words."""
18
+ return len(text.split())
19
+
20
+
21
+ def _echo(request: LlmRequest) -> str:
22
+ return request.messages[-1].content
23
+
24
+
25
+ class FakeProvider:
26
+ """Replies from a script, counts tokens as words, and can fail on purpose.
27
+
28
+ - `script`: a queue of reply texts (an `LlmError` is raised once it runs out) or a
29
+ function of the request. By default it echoes the last message.
30
+ - `fail`: the first `fail` calls raise a `ProviderError`, retryable (HTTP 503) when
31
+ `retryable` is true, fatal (HTTP 400) otherwise.
32
+ - `local`: whether to act as a self-hosted model, the only kind that may see 'nda'.
33
+ - `model`: the model id reported in responses; by default the requested one.
34
+
35
+ Input tokens are the words of every message, output tokens the words of the reply.
36
+ Every request received (failed ones included) is kept in `requests`.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ script: Script | None = None,
42
+ *,
43
+ name: str = "fake",
44
+ local: bool = False,
45
+ fail: int = 0,
46
+ retryable: bool = True,
47
+ model: str | None = None,
48
+ ) -> None:
49
+ self.name = name
50
+ self.local = local
51
+ self.requests: list[LlmRequest] = []
52
+ self._fail = fail
53
+ self._retryable = retryable
54
+ self._model = model
55
+ self._reply: Callable[[LlmRequest], str]
56
+ if script is None:
57
+ self._reply = _echo
58
+ elif callable(script):
59
+ self._reply = script
60
+ else:
61
+ self._queue = deque(script)
62
+ self._reply = self._next_in_queue
63
+
64
+ def _next_in_queue(self, request: LlmRequest) -> str:
65
+ if not self._queue:
66
+ raise LlmError(f"fake provider {self.name!r}: the scripted replies ran out")
67
+ return self._queue.popleft()
68
+
69
+ async def complete(self, request: LlmRequest) -> LlmResponse:
70
+ ensure_nda_allowed(self.name, self.local, request)
71
+ self.requests.append(request)
72
+ if self._fail > 0:
73
+ self._fail -= 1
74
+ status = 503 if self._retryable else 400
75
+ kind = "retryable" if self._retryable else "fatal"
76
+ raise ProviderError(
77
+ f"fake provider {self.name!r}: scripted {kind} failure (HTTP {status})",
78
+ retryable=self._retryable,
79
+ status=status,
80
+ )
81
+ text = self._reply(request)
82
+ return LlmResponse(
83
+ text=text,
84
+ input_tokens=sum(count_tokens(m.content) for m in request.messages),
85
+ output_tokens=count_tokens(text),
86
+ model=self._model or request.model,
87
+ )
@@ -0,0 +1,68 @@
1
+ """`GuardedProvider`: the nda rule, the budget and retry, wrapped around any provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+
8
+ from chipgraph.adapters.llm._budget import Ledger, Usage
9
+ from chipgraph.adapters.llm._nda import ensure_nda_allowed
10
+ from chipgraph.adapters.llm._retry import Rand, RetryPolicy, Sleep, call_with_retry
11
+ from chipgraph.core.config.models import DataCfg
12
+ from chipgraph.core.plugin_api.protocols import LlmProvider
13
+ from chipgraph.core.plugin_api.types import LlmRequest, LlmResponse
14
+
15
+
16
+ class GuardedProvider:
17
+ """Wraps an `LlmProvider` with the checks every API-runtime call needs.
18
+
19
+ For each `complete`, in order:
20
+
21
+ 1. **nda**: a request labeled 'nda' is refused with `NdaBlocked`, before any call,
22
+ unless `inner.local` is true and (when `data` is given) `data.nda_model` is 'local'.
23
+ 2. **budget**: before each try, `ledger.check()` raises `BudgetExceeded` if the run's
24
+ budget is spent, so the call does not start.
25
+ 3. **retry**: transient failures (429, 5xx, timeouts, connection errors) are retried
26
+ under `retry` with exponential backoff and jitter; any other failure propagates at
27
+ once, and `RetriesExhausted` is raised when every try failed.
28
+ 4. **accounting**: the response's tokens (and cost, when the model has a price) are
29
+ added to `ledger.usage`.
30
+
31
+ It satisfies `LlmProvider` itself, with the inner provider's `name` and `local`.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ inner: LlmProvider,
37
+ *,
38
+ ledger: Ledger | None = None,
39
+ data: DataCfg | None = None,
40
+ retry: RetryPolicy | None = None,
41
+ sleep: Sleep = asyncio.sleep,
42
+ rand: Rand = random.random,
43
+ ) -> None:
44
+ self.inner = inner
45
+ self.name = inner.name
46
+ self.local = inner.local
47
+ self.ledger = ledger if ledger is not None else Ledger()
48
+ self.data = data
49
+ self.retry = retry if retry is not None else RetryPolicy()
50
+ self._sleep = sleep
51
+ self._rand = rand
52
+
53
+ @property
54
+ def usage(self) -> Usage:
55
+ """The usage so far of this provider's ledger."""
56
+ return self.ledger.usage
57
+
58
+ async def complete(self, request: LlmRequest) -> LlmResponse:
59
+ ensure_nda_allowed(self.inner.name, self.inner.local, request, self.data)
60
+ response = await call_with_retry(
61
+ lambda: self.inner.complete(request),
62
+ self.retry,
63
+ sleep=self._sleep,
64
+ rand=self._rand,
65
+ before_try=self.ledger.check,
66
+ )
67
+ self.ledger.record(response)
68
+ return response
@@ -0,0 +1,22 @@
1
+ """Log parser adapters: turn a tool's raw log text into structured `Issue`s."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from chipgraph.adapters.parser.generic_regex import GenericRegexParser
8
+ from chipgraph.adapters.parser.verible import VeribleParser
9
+ from chipgraph.adapters.parser.verilator import VerilatorParser
10
+ from chipgraph.core.plugin_api.protocols import LogParser
11
+
12
+ __all__ = ["PARSERS", "GenericRegexParser", "VeribleParser", "VerilatorParser"]
13
+
14
+ #: Built-in parsers that need no arguments, keyed by `LogParser.name`.
15
+ #:
16
+ #: `generic-regex` (`GenericRegexParser`) is deliberately not listed here: it always
17
+ #: needs a `pattern` (and optionally `severity_group`/`default_severity`), so callers
18
+ #: construct it directly, e.g. `GenericRegexParser(pattern=spec.args["regex"])`.
19
+ PARSERS: dict[str, Callable[[], LogParser]] = {
20
+ "verilator": VerilatorParser,
21
+ "verible": VeribleParser,
22
+ }
@@ -0,0 +1,304 @@
1
+ """Parser for cocotb 2.x results: `results.xml` (+ the build and run logs) into `Issue`s.
2
+
3
+ cocotb writes a JUnit-style `results.xml` at `COCOTB_RESULTS_FILE`. It is the **only**
4
+ signal of what the tests did (docs/spikes/S3.md): a failing test, an exception in a
5
+ test, a test module that fails to import and a GPI that never loaded Python all leave
6
+ the simulator's exit code at 0. So:
7
+
8
+ - each `<testcase>` with a `<failure>` (or `<error>`) is one `Issue` with rule
9
+ ``cocotb/<ExceptionType>`` (``cocotb/AssertionError`` usually means the DUT is wrong,
10
+ any other type that the testbench is), its file and line taken from the **last
11
+ traceback frame in the test's own file** (the testcase's `file` property; its `line`
12
+ property, the line of the test's `def`, is only a fallback), and the message
13
+ ``<module>.<test>: <message>``; a passing test produces no issue;
14
+ - a `<skipped>` test is an `info` issue (``cocotb/skipped``); it never turns "nothing
15
+ ran" into a pass;
16
+ - a missing, empty or unparseable `results.xml`, one with zero testcases, or one whose
17
+ every test was skipped is ``status="error"`` with rule ``cocotb/no_results``, never
18
+ ``pass``; the reason is the last Python exception or ``ERROR gpi`` line of the run log;
19
+ - a failed build is ``error`` with the compiler's messages: Verilator's through
20
+ `VerilatorParser` (rule ``verilator/<code>``, ``verilator/error`` for a plain
21
+ ``%Error``) and Icarus's ``file:line: error: ...``/``file:line: syntax error`` lines
22
+ (rule ``icarus/error``).
23
+
24
+ `CocotbParser.parse(text)` is the `LogParser` view (the text of a `results.xml` in, its
25
+ issues out; a bad or empty one yields the ``cocotb/no_results`` error issue), and
26
+ `CocotbParser.parse_run(...)` classifies a whole build + run into a `CocotbOutcome` with
27
+ a `CheckStatus`, which is what `EdalizeTool` uses.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ import xml.etree.ElementTree as ET
35
+ from collections.abc import Callable
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Literal
39
+
40
+ from chipgraph.adapters.parser.verilator import VerilatorParser
41
+ from chipgraph.core.contracts import Issue
42
+ from chipgraph.core.contracts.types import CheckStatus, Severity
43
+
44
+ __all__ = [
45
+ "NO_RESULTS_RULE",
46
+ "CocotbOutcome",
47
+ "CocotbParser",
48
+ "CocotbTest",
49
+ "build_issues",
50
+ "parse_icarus_log",
51
+ ]
52
+
53
+ NO_RESULTS_RULE = "cocotb/no_results"
54
+
55
+ _FRAME_RE = re.compile(r'^\s*File "(?P<file>[^"]+)", line (?P<line>\d+)', re.MULTILINE)
56
+ _PY_EXC_RE = re.compile(
57
+ r"^(?P<type>[A-Za-z_][\w.]*(?:Error|Exception|Exit|Interrupt)): (?P<msg>.*)$", re.MULTILINE
58
+ )
59
+ _GPI_ERR_RE = re.compile(r"\bERROR\s+gpi\b.*?\bin \S+\s+(?P<msg>\S.*)$", re.MULTILINE)
60
+ _ICARUS_RE = re.compile(
61
+ r"^(?P<file>[^\s:][^:\n]*):(?P<line>\d+):\s+"
62
+ r"(?:(?P<kind>error|warning|sorry):\s*(?P<msg>.*)|(?P<syntax>syntax error.*)"
63
+ r"|vvp\.tgt sorry:\s*(?P<tgt>.*))$",
64
+ re.MULTILINE,
65
+ )
66
+
67
+ Simulator = Literal["verilator", "icarus"]
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class CocotbTest:
72
+ """One `<testcase>` of a `results.xml`: its full name and what happened to it."""
73
+
74
+ name: str
75
+ outcome: Literal["pass", "fail", "skipped"]
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class CocotbOutcome:
80
+ """A classified build + run: the check status, its issues, and the tests seen."""
81
+
82
+ status: CheckStatus
83
+ issues: tuple[Issue, ...]
84
+ tests: tuple[CocotbTest, ...] = ()
85
+
86
+ @property
87
+ def summary(self) -> str:
88
+ """``tests=N pass=P fail=F skipped=S``, for a log line."""
89
+ count = {k: sum(t.outcome == k for t in self.tests) for k in ("pass", "fail", "skipped")}
90
+ return (
91
+ f"tests={len(self.tests)} pass={count['pass']} fail={count['fail']} "
92
+ f"skipped={count['skipped']}"
93
+ )
94
+
95
+
96
+ class CocotbParser:
97
+ """Turns cocotb's `results.xml` (and the simulator's logs) into `Issue`s."""
98
+
99
+ name = "cocotb"
100
+
101
+ def parse(self, log: str) -> tuple[Issue, ...]:
102
+ """`LogParser` view: `log` is the text of a `results.xml`.
103
+
104
+ A passing run yields no error issue (skipped tests are `info`). An empty or
105
+ unparseable text, or one with no test that actually ran, yields one
106
+ ``cocotb/no_results`` error issue, so a caller that only looks at issue
107
+ severities can never read "nothing ran" as a pass.
108
+ """
109
+ return self.parse_results(log).issues
110
+
111
+ def parse_results(self, xml_text: str | None, *, run_log: str = "") -> CocotbOutcome:
112
+ """Classify the text of a `results.xml` (None: the file does not exist)."""
113
+ if xml_text is None:
114
+ return _no_results("cocotb wrote no results.xml: no test ran", run_log)
115
+ if not xml_text.strip():
116
+ return _no_results("results.xml is empty: no test ran", run_log)
117
+ try:
118
+ root = ET.fromstring(xml_text)
119
+ except ET.ParseError as exc:
120
+ return _no_results(f"results.xml is not valid XML ({exc})", run_log)
121
+
122
+ tests: list[CocotbTest] = []
123
+ issues: list[Issue] = []
124
+ for case in root.iter("testcase"):
125
+ name = ".".join(p for p in (case.get("classname", ""), case.get("name", "")) if p)
126
+ props = {p.get("name"): p.get("value") for p in case.iter("property")}
127
+ bad = case.find("failure")
128
+ if bad is None:
129
+ bad = case.find("error")
130
+ if bad is not None:
131
+ tests.append(CocotbTest(name, "fail"))
132
+ issues.append(_failure_issue(name, bad, props))
133
+ elif case.find("skipped") is not None:
134
+ tests.append(CocotbTest(name, "skipped"))
135
+ issues.append(Issue(msg=f"{name}: skipped", rule="cocotb/skipped", severity="info"))
136
+ else:
137
+ tests.append(CocotbTest(name, "pass"))
138
+
139
+ if not tests:
140
+ return _no_results("results.xml has no testcase: no test ran", run_log)
141
+ if all(t.outcome == "skipped" for t in tests):
142
+ why = f"every test was skipped ({len(tests)}): no test ran"
143
+ no_run = _no_results(why, run_log).issues
144
+ return CocotbOutcome("error", (*issues, *no_run), tuple(tests))
145
+ status: CheckStatus = "fail" if any(t.outcome == "fail" for t in tests) else "pass"
146
+ return CocotbOutcome(status, tuple(issues), tuple(tests))
147
+
148
+ def parse_run(
149
+ self,
150
+ results: Path | None,
151
+ *,
152
+ simulator: Simulator,
153
+ build_rc: int,
154
+ build_log: str = "",
155
+ build_timed_out: bool = False,
156
+ run_rc: int | None = None,
157
+ run_log: str = "",
158
+ run_timed_out: bool = False,
159
+ relativize: Callable[[str], str] | None = None,
160
+ ) -> CocotbOutcome:
161
+ """Classify one build + run of a cocotb testbench.
162
+
163
+ `build_rc` is the build's exit code; `run_rc` is the run's (None when the run did
164
+ not start). `results` is the path cocotb was told to write (it may not exist, and
165
+ the caller must have removed any stale one before the run). `relativize` maps a
166
+ file path in an issue to the form the caller wants (e.g. repo-relative).
167
+
168
+ The outcome is `pass` only with a `results.xml` that has at least one test that
169
+ ran, none that failed, a run that exited 0 and no timeout. Failing tests are
170
+ `fail`; everything else (build failure, timeout, no or bad results) is `error`.
171
+ """
172
+ rel = relativize or (lambda path: path)
173
+ if build_rc != 0 or build_timed_out:
174
+ issues = list(build_issues(build_log, simulator))
175
+ if build_timed_out:
176
+ issues.append(Issue(msg="the build timed out", rule="sim/timeout"))
177
+ return CocotbOutcome("error", _relativize(issues, rel))
178
+ if run_rc is None:
179
+ msg = "the simulation did not start"
180
+ return CocotbOutcome("error", (Issue(msg=msg, rule=NO_RESULTS_RULE),))
181
+
182
+ text = _read_text(results) if results is not None else None
183
+ outcome = self.parse_results(text, run_log=run_log)
184
+ issues = list(outcome.issues)
185
+ status = outcome.status
186
+ if run_timed_out:
187
+ issues.append(Issue(msg="the simulation timed out", rule="sim/timeout"))
188
+ status = "fail" if status == "fail" else "error"
189
+ elif status == "pass" and run_rc != 0:
190
+ msg = f"the simulator exited with {run_rc} after the tests"
191
+ issues.append(Issue(msg=msg, rule="sim/exit"))
192
+ status = "error"
193
+ return CocotbOutcome(status, _relativize(issues, rel), outcome.tests)
194
+
195
+
196
+ def build_issues(log: str, simulator: Simulator) -> tuple[Issue, ...]:
197
+ """Compiler messages from a failed build; one generic issue if none is recognised."""
198
+ found: list[Issue] = []
199
+ for issue in VerilatorParser().parse(log):
200
+ code = issue.rule or ("error" if issue.severity == "error" else "warning")
201
+ found.append(issue.model_copy(update={"rule": f"verilator/{code}"}))
202
+ found.extend(parse_icarus_log(log))
203
+ if not any(i.severity == "error" for i in found):
204
+ last = _last_meaningful_line(log)
205
+ detail = f": {last}" if last else ""
206
+ found.append(Issue(msg=f"the {simulator} build failed{detail}", rule="sim/build"))
207
+ return tuple(found)
208
+
209
+
210
+ def parse_icarus_log(log: str) -> tuple[Issue, ...]:
211
+ """Icarus Verilog's ``file:line: error: msg`` / ``file:line: syntax error`` lines.
212
+
213
+ Errors get rule ``icarus/error``; ``warning:`` lines ``icarus/warning`` and
214
+ ``sorry:`` lines (unsupported constructs Icarus ignores) ``icarus/sorry``, both
215
+ warnings.
216
+ """
217
+ issues: list[Issue] = []
218
+ for m in _ICARUS_RE.finditer(log):
219
+ kind = m["kind"] or ("error" if m["syntax"] else "sorry")
220
+ msg = (m["msg"] or m["syntax"] or m["tgt"] or "").strip()
221
+ severity: Severity = "error" if kind == "error" else "warning"
222
+ issues.append(
223
+ Issue(
224
+ file=m["file"],
225
+ line=int(m["line"]),
226
+ rule=f"icarus/{kind}",
227
+ severity=severity,
228
+ msg=msg,
229
+ )
230
+ )
231
+ return tuple(issues)
232
+
233
+
234
+ def _failure_issue(name: str, bad: ET.Element, props: dict[str | None, str | None]) -> Issue:
235
+ kind = bad.get("type") or bad.tag
236
+ message = (bad.get("message") or "").strip() or kind
237
+ test_file = props.get("file")
238
+ file, line = _failure_location(bad.text or "", test_file)
239
+ if line is None and props.get("line"):
240
+ line = _int_or_none(props.get("line"))
241
+ return Issue(file=file, line=line, rule=f"cocotb/{kind}", msg=f"{name}: {message}")
242
+
243
+
244
+ def _failure_location(traceback: str, test_file: str | None) -> tuple[str | None, int | None]:
245
+ """The last traceback frame in `test_file`; else the test's own file, no line yet."""
246
+ frames = [(m["file"], int(m["line"])) for m in _FRAME_RE.finditer(traceback)]
247
+ if test_file:
248
+ own = [f for f in frames if _same_file(f[0], test_file)]
249
+ if own:
250
+ return own[-1]
251
+ return test_file, None
252
+ return frames[-1] if frames else (None, None)
253
+
254
+
255
+ def _same_file(a: str, b: str) -> bool:
256
+ return a == b or os.path.realpath(a) == os.path.realpath(b)
257
+
258
+
259
+ def _no_results(why: str, run_log: str) -> CocotbOutcome:
260
+ reason = _run_log_reason(run_log)
261
+ msg = f"{why} ({reason})" if reason else why
262
+ return CocotbOutcome("error", (Issue(msg=msg, rule=NO_RESULTS_RULE),))
263
+
264
+
265
+ def _run_log_reason(run_log: str) -> str:
266
+ """The last Python exception, else the last ``ERROR gpi`` message, of a run log."""
267
+ exc = list(_PY_EXC_RE.finditer(run_log))
268
+ if exc:
269
+ return f"{exc[-1]['type']}: {exc[-1]['msg'].strip()}"
270
+ gpi = list(_GPI_ERR_RE.finditer(run_log))
271
+ if gpi:
272
+ return f"gpi: {gpi[-1]['msg'].strip()}"
273
+ return ""
274
+
275
+
276
+ def _last_meaningful_line(log: str) -> str:
277
+ for line in reversed(log.splitlines()):
278
+ text = line.strip()
279
+ if text and not text.startswith("make") and not text.startswith("%Error: Exiting"):
280
+ return text[:200]
281
+ return ""
282
+
283
+
284
+ def _relativize(issues: list[Issue], rel: Callable[[str], str]) -> tuple[Issue, ...]:
285
+ return tuple(
286
+ i.model_copy(update={"file": rel(i.file)}) if i.file is not None else i for i in issues
287
+ )
288
+
289
+
290
+ def _read_text(path: Path) -> str | None:
291
+ try:
292
+ return path.read_text(encoding="utf-8", errors="replace")
293
+ except FileNotFoundError:
294
+ return None
295
+ except OSError:
296
+ return ""
297
+
298
+
299
+ def _int_or_none(raw: str | None) -> int | None:
300
+ try:
301
+ value = int(raw) if raw is not None else None
302
+ except ValueError:
303
+ return None
304
+ return value if value is not None and value >= 1 else None