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,157 @@
1
+ """Which project files `/ask` may search, and indexing them into the model store.
2
+
3
+ `ModelStore.write()` rebuilds the database from scratch, which drops every document added
4
+ with `ModelStore.add_document`. So `chipgraph ingest` calls `index_documents` right after
5
+ it writes the model: every ingest re-indexes the same, deterministic document set.
6
+
7
+ The document set is:
8
+
9
+ - every input file ingest read (the chip-level spec, each block's spec, each filelist and
10
+ the RTL sources it lists), and
11
+ - the project's own Markdown docs: `README.md`, `AGENTS.md`, `doc/**/*.md`, `docs/**/*.md`,
12
+
13
+ minus files labelled `nda` (every `/ask` model may be a cloud model, so an `nda` file is
14
+ never indexed; ingest reports each one it skipped), vendored paths (a `vendor`,
15
+ `third_party`, ... path segment), paths outside the project, and binary or very large files.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Literal
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field
25
+
26
+ from chipgraph.core.model.store import ModelStore
27
+ from chipgraph.core.state.artifacts import LabelRules
28
+
29
+ MARKDOWN_DOCS: tuple[str, ...] = ("README.md", "AGENTS.md", "doc/**/*.md", "docs/**/*.md")
30
+ """Globs (relative to the project root) of the Markdown docs indexed besides ingest inputs."""
31
+
32
+ VENDOR_SEGMENTS = frozenset({"vendor", "third_party", "thirdparty", "external", "extern"})
33
+ """A path with one of these segments is vendored code: never indexed."""
34
+
35
+ MAX_DOCUMENT_BYTES = 2 * 1024 * 1024
36
+ """Files larger than this (generated netlists, dumps) are not indexed."""
37
+
38
+ SkipReason = Literal["nda", "vendor", "outside_root", "missing", "binary", "too_large"]
39
+
40
+
41
+ class SkippedDocument(BaseModel):
42
+ """A candidate document that was not indexed, and why."""
43
+
44
+ model_config = ConfigDict(extra="forbid", frozen=True)
45
+
46
+ path: str = Field(description="Repo-relative POSIX path (or the path as given).")
47
+ reason: SkipReason = Field(description="Why it was not indexed.")
48
+
49
+
50
+ class DocumentIndexReport(BaseModel):
51
+ """What `index_documents` indexed for `/ask`, and what it skipped."""
52
+
53
+ model_config = ConfigDict(extra="forbid", frozen=True)
54
+
55
+ schema_version: int = 1
56
+ indexed: tuple[str, ...] = Field(default=(), description="Indexed paths, sorted.")
57
+ lines: int = Field(default=0, description="Total number of indexed lines.")
58
+ skipped: tuple[SkippedDocument, ...] = Field(
59
+ default=(), description="Candidates not indexed, sorted by path."
60
+ )
61
+
62
+ @property
63
+ def nda_skipped(self) -> tuple[str, ...]:
64
+ """The paths skipped because they are labelled `nda`."""
65
+ return tuple(s.path for s in self.skipped if s.reason == "nda")
66
+
67
+
68
+ def document_candidates(root: Path, input_paths: Iterable[str]) -> list[str]:
69
+ """Every candidate document path: ingest inputs plus the Markdown docs, sorted, unique."""
70
+ candidates = {_normalise(p) for p in input_paths}
71
+ for pattern in MARKDOWN_DOCS:
72
+ for path in root.glob(pattern):
73
+ if path.is_file():
74
+ candidates.add(_relative(path, root))
75
+ return sorted(candidates)
76
+
77
+
78
+ def index_documents(
79
+ root: Path,
80
+ store: ModelStore,
81
+ input_paths: Iterable[str],
82
+ labels: LabelRules,
83
+ ) -> DocumentIndexReport:
84
+ """Index the `/ask` document set of the project at `root` into `store`.
85
+
86
+ Call it after `store.write()`, which drops earlier documents. Deterministic: the same
87
+ files give the same rows, in the same order.
88
+ """
89
+ indexed: list[str] = []
90
+ skipped: list[SkippedDocument] = []
91
+ lines = 0
92
+ for rel in document_candidates(root, input_paths):
93
+ reason = _skip_reason(rel, root, labels)
94
+ if reason is not None:
95
+ skipped.append(SkippedDocument(path=rel, reason=reason))
96
+ continue
97
+ data = (root / rel).read_bytes()
98
+ if len(data) > MAX_DOCUMENT_BYTES:
99
+ skipped.append(SkippedDocument(path=rel, reason="too_large"))
100
+ continue
101
+ if b"\x00" in data:
102
+ skipped.append(SkippedDocument(path=rel, reason="binary"))
103
+ continue
104
+ text = data.decode("utf-8", errors="replace")
105
+ store.add_document(rel, text)
106
+ indexed.append(rel)
107
+ lines += len(text.splitlines())
108
+ return DocumentIndexReport(indexed=tuple(indexed), lines=lines, skipped=tuple(skipped))
109
+
110
+
111
+ def is_vendor_path(rel: str) -> bool:
112
+ """True when `rel` has a vendored-code path segment."""
113
+ return any(part.lower() in VENDOR_SEGMENTS for part in PurePosixPath(rel).parts)
114
+
115
+
116
+ def _skip_reason(rel: str, root: Path, labels: LabelRules) -> SkipReason | None:
117
+ posix = PurePosixPath(rel)
118
+ if posix.is_absolute() or ".." in posix.parts:
119
+ return "outside_root"
120
+ if labels.label_for(rel) == "nda":
121
+ return "nda"
122
+ if is_vendor_path(rel):
123
+ return "vendor"
124
+ path = root / rel
125
+ if not path.is_file():
126
+ return "missing"
127
+ try:
128
+ path.resolve().relative_to(root.resolve())
129
+ except ValueError:
130
+ return "outside_root" # a symlink out of the project
131
+ return None
132
+
133
+
134
+ def _normalise(rel: str) -> str:
135
+ text = rel.replace("\\", "/")
136
+ while text.startswith("./"):
137
+ text = text[2:]
138
+ return text
139
+
140
+
141
+ def _relative(path: Path, root: Path) -> str:
142
+ try:
143
+ return path.resolve().relative_to(root.resolve()).as_posix()
144
+ except ValueError:
145
+ return path.as_posix()
146
+
147
+
148
+ __all__ = [
149
+ "MARKDOWN_DOCS",
150
+ "MAX_DOCUMENT_BYTES",
151
+ "VENDOR_SEGMENTS",
152
+ "DocumentIndexReport",
153
+ "SkippedDocument",
154
+ "document_candidates",
155
+ "index_documents",
156
+ "is_vendor_path",
157
+ ]
@@ -0,0 +1,80 @@
1
+ """A safe FTS5 query from a free-text question (FTS5 syntax never reaches SQLite raw).
2
+
3
+ The question is tokenised into words (Unicode letters, digits, `_`, with inner `-` or
4
+ `.`, so `REQ-TIM-004`, `CTRL.EN` and `pin_out` stay one term). English stop words and
5
+ one-character words are dropped, a plural gets its singular as an extra term, every term
6
+ is quoted as an FTS5 string, and the terms are ORed. A quoted string is never an FTS5
7
+ operator (`NEAR`, `AND`, `*`, `^`, `col:`), and the tokeniser splits it into a phrase
8
+ exactly as it split the indexed text. A question with no usable term gives `None`: no
9
+ search at all.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ MAX_TERMS = 32
17
+ """At most this many terms go into one query (the first ones in the question)."""
18
+
19
+ _TERM = re.compile(r"\w+(?:[-.]\w+)*")
20
+
21
+ _STOP_WORDS_TEXT = """
22
+ a about above after again all also am an and any are as at be been before being below
23
+ between both but by can could did do does doing done down during each either else
24
+ for from further get gets give given had has have having he her here hers how however
25
+ i if in into is it its itself just let me more most much must my no nor not now of
26
+ off on once only or other our out over own per please same she should show so some
27
+ such tell than that the their them then there these they this those through to too
28
+ under until up upon us very via was we were what whats when where whether which while
29
+ who whom whose why will with within without would yes you your
30
+ """
31
+ STOP_WORDS = frozenset(_STOP_WORDS_TEXT.split())
32
+ """English function words and question words: never a search term on their own.
33
+
34
+ `out`, `in` and `no` are stop words too: as ports (`pin_out`) they are kept inside the
35
+ longer term, and a typed lookup still finds an entity named exactly that.
36
+ """
37
+
38
+
39
+ def question_terms(question: str) -> list[str]:
40
+ """The search terms of `question`, lowercased, in order, without duplicates."""
41
+ terms: list[str] = []
42
+ seen: set[str] = set()
43
+ for match in _TERM.finditer(question):
44
+ word = match.group(0).strip("-.").lower()
45
+ if len(word) < 2 or word in STOP_WORDS:
46
+ continue
47
+ for term in (word, _singular(word)):
48
+ if term and term not in seen and term not in STOP_WORDS:
49
+ seen.add(term)
50
+ terms.append(term)
51
+ return terms[:MAX_TERMS]
52
+
53
+
54
+ def fts_query(question: str) -> str | None:
55
+ """A safe FTS5 MATCH expression for `question`: quoted terms ORed, or `None`."""
56
+ terms = question_terms(question)
57
+ if not terms:
58
+ return None
59
+ return " OR ".join(_quote(term) for term in terms)
60
+
61
+
62
+ def _quote(term: str) -> str:
63
+ # Terms only hold word characters, `-` and `.`; doubling `"` keeps this safe anyway.
64
+ return '"' + term.replace('"', '""') + '"'
65
+
66
+
67
+ def _singular(word: str) -> str | None:
68
+ """A naive singular for an English plural (`registers` -> `register`), else None."""
69
+ if not word.isalpha() or len(word) < 5:
70
+ return None
71
+ if word.endswith("ies"):
72
+ return word[:-3] + "y"
73
+ if word.endswith("sses") or word.endswith("xes"):
74
+ return word[:-2]
75
+ if word.endswith("s") and not word.endswith(("ss", "us", "is")):
76
+ return word[:-1]
77
+ return None
78
+
79
+
80
+ __all__ = ["MAX_TERMS", "STOP_WORDS", "fts_query", "question_terms"]
@@ -0,0 +1,331 @@
1
+ """`ask_context`: the sources an `/ask` answer may use (DESIGN 4.5: typed queries, no grep).
2
+
3
+ Two kinds of retrieval, merged:
4
+
5
+ 1. **Typed lookups** (`ModelQuery`): a word of the question that names something the model
6
+ knows (a block, module, register, `REG.FIELD`, port, requirement id, clock, reset, ...)
7
+ gives that entity, with its facts and relations; a kind word (`register`, `interrupt`,
8
+ `open items`, `memory map`, ...) lists the entities of that kind in the blocks the
9
+ question is about. A port named `clk` exists in every module, so an entity in a block
10
+ the question names ranks above one in another block.
11
+ 2. **Full-text search** (FTS5 over entities and documents) with a safe query built from
12
+ the question (`fts.fts_query`). A document hit cites `path:line` and carries the lines
13
+ around it.
14
+
15
+ Entities and documents from `nda` files are never returned. An empty result is a valid
16
+ answer: `no_sources` is then true and the answer must be "I don't know".
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import sqlite3
23
+
24
+ from chipgraph.app.context import AppContext
25
+ from chipgraph.core.model.entities import EntityBase
26
+ from chipgraph.packs.assist.ask._project import AskProject
27
+ from chipgraph.packs.assist.ask.contract import AskContext, AskSource, DocLine
28
+ from chipgraph.packs.assist.ask.fts import STOP_WORDS, fts_query
29
+
30
+ DEFAULT_LIMIT = 12
31
+ MAX_LIMIT = 50
32
+ CONTEXT_LINES = 2
33
+ """Lines shown before and after a document hit."""
34
+
35
+ _WORD = re.compile(r"\w+(?:[-.]\w+)*")
36
+
37
+ _KIND_PRIORITY: dict[str, int] = {
38
+ "requirement": 10,
39
+ "register": 9,
40
+ "field": 8,
41
+ "interrupt": 8,
42
+ "memory_region": 7,
43
+ "block": 6,
44
+ "module": 6,
45
+ "port": 5,
46
+ "clock": 5,
47
+ "reset": 5,
48
+ "parameter": 4,
49
+ "interface": 4,
50
+ "open_item": 4,
51
+ "project": 3,
52
+ }
53
+
54
+ _KIND_WORDS: dict[str, str] = {
55
+ "register": "register",
56
+ "registers": "register",
57
+ "field": "field",
58
+ "fields": "field",
59
+ "bitfield": "field",
60
+ "bitfields": "field",
61
+ "requirement": "requirement",
62
+ "requirements": "requirement",
63
+ "interrupt": "interrupt",
64
+ "interrupts": "interrupt",
65
+ "port": "port",
66
+ "ports": "port",
67
+ "signal": "port",
68
+ "signals": "port",
69
+ "module": "module",
70
+ "modules": "module",
71
+ "parameter": "parameter",
72
+ "parameters": "parameter",
73
+ "clock": "clock",
74
+ "clocks": "clock",
75
+ "reset": "reset",
76
+ "resets": "reset",
77
+ "block": "block",
78
+ "blocks": "block",
79
+ "bus": "interface",
80
+ "buses": "interface",
81
+ "interface": "interface",
82
+ "interfaces": "interface",
83
+ "address": "memory_region",
84
+ "addresses": "memory_region",
85
+ }
86
+ _PHRASE_KINDS: tuple[tuple[str, str], ...] = (
87
+ ("open item", "open_item"),
88
+ ("open question", "open_item"),
89
+ ("memory map", "memory_region"),
90
+ ("address map", "memory_region"),
91
+ )
92
+
93
+ _GENERIC_WORDS = frozenset(
94
+ {
95
+ "access", "address", "base", "block", "clock", "config", "control", "count", "data",
96
+ "enable", "field", "interrupt", "line", "mode", "module", "offset", "port", "register",
97
+ "reset", "size", "status", "type", "value", "width",
98
+ }
99
+ ) # fmt: skip
100
+ """Words that are also common register or field names: they match an entity only when
101
+ written exactly as its name."""
102
+
103
+ _DIRECT_BONUS = 10
104
+ _KIND_WORD_BONUS = 6
105
+ _CONTEXT_BONUS = 5
106
+ _OFF_CONTEXT_PENALTY = 6
107
+ _MAX_PER_KIND_LISTING = 8
108
+
109
+
110
+ def ask_context(ctx: AppContext, question: str, limit: int = DEFAULT_LIMIT) -> AskContext:
111
+ """The sources for `question` in the project of `ctx`, best first, at most `limit`."""
112
+ project = AskProject.load(ctx)
113
+ return retrieve(project, question, limit)
114
+
115
+
116
+ def retrieve(project: AskProject, question: str, limit: int = DEFAULT_LIMIT) -> AskContext:
117
+ """`ask_context` on an already loaded `AskProject`."""
118
+ limit = max(1, min(limit, MAX_LIMIT))
119
+ typed = _typed_sources(project, question, limit)
120
+ query = fts_query(question)
121
+ searched = _search_sources(project, query, limit) if query is not None else []
122
+
123
+ sources: list[AskSource] = []
124
+ seen: set[str] = set()
125
+
126
+ def take(items: list[AskSource], cap: int) -> None:
127
+ for source in items:
128
+ if len(sources) >= cap:
129
+ return
130
+ if source.citation not in seen:
131
+ seen.add(source.citation)
132
+ sources.append(source)
133
+
134
+ take(typed, max(1, (limit + 1) // 2))
135
+ take(searched, limit)
136
+ take(typed, limit)
137
+
138
+ no_sources = not sources
139
+ note = (
140
+ "No source matches the question: answer that you do not know (unknown: true)."
141
+ if no_sources
142
+ else "Answer only from these sources. Cite every claim with a source's `citation` "
143
+ "(or its `defined_at`) exactly as given. If they do not answer the question, set "
144
+ "unknown: true and say what is missing. Do not use outside knowledge."
145
+ )
146
+ return AskContext(
147
+ question=question,
148
+ query=query,
149
+ sources=tuple(sources),
150
+ no_sources=no_sources,
151
+ note=note,
152
+ )
153
+
154
+
155
+ # --- typed lookups ----------------------------------------------------------------------
156
+
157
+
158
+ def _typed_sources(project: AskProject, question: str, limit: int) -> list[AskSource]:
159
+ """Typed lookups on the names and kind words of the question, best first.
160
+
161
+ Score = kind priority, + `_DIRECT_BONUS` for an entity the question names, +
162
+ `_KIND_WORD_BONUS` when its kind is named too (or it is listed for a kind word in the
163
+ blocks the question is about), +/- the block context. An entity that only shares its
164
+ name with a named block (the `timer` memory region when the question says "timer")
165
+ is not "named" unless its kind is.
166
+ """
167
+ words = [m.group(0).strip("-.") for m in _WORD.finditer(question)]
168
+ words = [w for w in words if len(w) >= 2]
169
+ kinds = _kinds_named(words)
170
+ names: dict[str, list[EntityBase]] = {}
171
+ for entity in project.model.entities.values():
172
+ if entity.kind in _KIND_PRIORITY and project.visible(entity):
173
+ names.setdefault(entity.name.lower(), []).append(entity)
174
+
175
+ direct: dict[str, EntityBase] = {}
176
+ for word in words:
177
+ lowered = word.lower()
178
+ if lowered in STOP_WORDS:
179
+ continue
180
+ for entity in names.get(lowered, ()):
181
+ # A generic word ("reset value", "status") names an entity only when it is
182
+ # written exactly as that entity's name (`RESET`, `STATUS`).
183
+ if lowered in _GENERIC_WORDS and word != entity.name:
184
+ continue
185
+ direct.setdefault(entity.key, entity)
186
+ if "." in word:
187
+ for entity in _dotted_fields(project, word):
188
+ direct.setdefault(entity.key, entity)
189
+ block_names = {e.name.lower() for e in direct.values() if e.kind == "block"}
190
+ context_blocks, context_modules = _context(project, list(direct.values()))
191
+
192
+ # A kind word lists that kind in the blocks the question is about; with no such
193
+ # block, only when the whole project has a few of them (its one clock, its resets).
194
+ listed: dict[str, EntityBase] = {}
195
+ for kind in kinds:
196
+ members = [
197
+ e
198
+ for e in sorted(project.model.by_kind(kind), key=lambda e: e.key)
199
+ if project.visible(e) and (not context_blocks or project.blocks_of(e) & context_blocks)
200
+ ]
201
+ if context_blocks or len(members) <= _MAX_PER_KIND_LISTING:
202
+ for entity in members[:_MAX_PER_KIND_LISTING]:
203
+ listed.setdefault(entity.key, entity)
204
+
205
+ scored: list[tuple[int, str, EntityBase]] = []
206
+ for key, entity in {**listed, **direct}.items():
207
+ kind_named = entity.kind in kinds
208
+ named = key in direct and (
209
+ entity.kind == "block" or entity.name.lower() not in block_names or kind_named
210
+ )
211
+ score = _KIND_PRIORITY.get(entity.kind, 1)
212
+ if named:
213
+ score += _DIRECT_BONUS
214
+ if kind_named and (named or context_blocks):
215
+ score += _KIND_WORD_BONUS
216
+ score += _context_adjustment(project, entity, context_blocks, context_modules)
217
+ scored.append((score, key, entity))
218
+ scored.sort(key=lambda item: (-item[0], item[1]))
219
+ return [_model_source(project, entity, "lookup") for _, _, entity in scored[:limit]]
220
+
221
+
222
+ def _dotted_fields(project: AskProject, word: str) -> list[EntityBase]:
223
+ """`CTRL.EN` (register.field) or `timer.CTRL` (block.register) as model entities."""
224
+ parts = word.lower().split(".")
225
+ found = []
226
+ for entity in project.model.entities.values():
227
+ if entity.kind not in ("field", "register") or not project.visible(entity):
228
+ continue
229
+ key_parts = entity.key.partition(":")[2].lower().split(".")
230
+ if key_parts[-len(parts) :] == parts:
231
+ found.append(entity)
232
+ return found
233
+
234
+
235
+ def _context(project: AskProject, direct: list[EntityBase]) -> tuple[set[str], set[str]]:
236
+ """The blocks and modules the question is about.
237
+
238
+ The blocks and modules it names; if it names none, the blocks of the registers,
239
+ fields, requirements and memory regions it names.
240
+ """
241
+ modules = {e.key for e in direct if e.kind == "module"}
242
+ blocks: set[str] = set()
243
+ for entity in direct:
244
+ if entity.kind in ("block", "module"):
245
+ blocks |= project.blocks_of(entity)
246
+ if not blocks:
247
+ for entity in direct:
248
+ if entity.kind in ("register", "field", "requirement", "memory_region"):
249
+ blocks |= project.blocks_of(entity)
250
+ return blocks, modules
251
+
252
+
253
+ def _context_adjustment(
254
+ project: AskProject,
255
+ entity: EntityBase,
256
+ context_blocks: set[str],
257
+ context_modules: set[str],
258
+ ) -> int:
259
+ if entity.kind in ("block", "project"):
260
+ return 0
261
+ module = project.module_of(entity)
262
+ blocks = project.blocks_of(entity)
263
+ if context_modules and module is not None:
264
+ in_context = module in context_modules
265
+ elif context_blocks and blocks:
266
+ in_context = bool(blocks & context_blocks)
267
+ else:
268
+ return 0
269
+ return _CONTEXT_BONUS if in_context else -_OFF_CONTEXT_PENALTY
270
+
271
+
272
+ def _kinds_named(words: list[str]) -> list[str]:
273
+ """The entity kinds the question names ("registers", "open items", "memory map")."""
274
+ lowered = [w.lower() for w in words]
275
+ kinds: list[str] = []
276
+ for index, word in enumerate(lowered):
277
+ following = lowered[index + 1] if index + 1 < len(lowered) else ""
278
+ pair = f"{word} {following}"
279
+ named = next((k for p, k in _PHRASE_KINDS if pair.startswith(p)), None)
280
+ if named is None:
281
+ if word == "reset" and following.startswith("value"):
282
+ continue # "reset value" asks about a register, not about a reset
283
+ named = _KIND_WORDS.get(word)
284
+ if named is not None and named not in kinds:
285
+ kinds.append(named)
286
+ return kinds
287
+
288
+
289
+ def _model_source(project: AskProject, entity: EntityBase, origin: str) -> AskSource:
290
+ return AskSource(
291
+ citation=f"model:{entity.key}",
292
+ kind="model",
293
+ text=project.summary(entity),
294
+ origin=origin, # type: ignore[arg-type]
295
+ defined_at=project.defined_at(entity),
296
+ )
297
+
298
+
299
+ # --- full-text search -------------------------------------------------------------------
300
+
301
+
302
+ def _search_sources(project: AskProject, query: str, limit: int) -> list[AskSource]:
303
+ try:
304
+ hits = project.store.search(query, limit=limit * 2)
305
+ except sqlite3.Error:
306
+ return [] # a query the FTS5 parser still refuses: no search result, never an error
307
+ sources: list[AskSource] = []
308
+ for hit in hits:
309
+ if hit.key is not None:
310
+ entity = project.get(hit.key)
311
+ if entity is not None:
312
+ sources.append(_model_source(project, entity, "search"))
313
+ elif hit.file is not None and hit.line is not None:
314
+ if project.labels.label_for(hit.file) == "nda":
315
+ continue
316
+ sources.append(_document_source(project, hit.file, hit.line, hit.text))
317
+ return sources
318
+
319
+
320
+ def _document_source(project: AskProject, path: str, line: int, text: str) -> AskSource:
321
+ around = project.docs.lines(path, max(1, line - CONTEXT_LINES), line + CONTEXT_LINES)
322
+ return AskSource(
323
+ citation=f"{path}:{line}",
324
+ kind="document",
325
+ text=text,
326
+ origin="search",
327
+ context=tuple(DocLine(line=n, text=t) for n, t in around if n != line),
328
+ )
329
+
330
+
331
+ __all__ = ["CONTEXT_LINES", "DEFAULT_LIMIT", "MAX_LIMIT", "ask_context", "retrieve"]