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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""`decide()`: the fast decision layer for multiple-choice questions (DESIGN 5.4).
|
|
2
|
+
|
|
3
|
+
A `Question` has a fixed set of `choices` (classify a failing log, escalate or not, is
|
|
4
|
+
a command risky ...). `decide()` answers it in cost order:
|
|
5
|
+
|
|
6
|
+
1. **rules**: deterministic functions of the question, in order; the first that
|
|
7
|
+
returns a `Decision` wins (`backend="rule"`);
|
|
8
|
+
2. the **small** model tier, through a `ModelBackend`;
|
|
9
|
+
3. the **large** model tier, when the small answer's confidence is under the
|
|
10
|
+
threshold (or the answer is not one of the choices, which counts as confidence 0).
|
|
11
|
+
|
|
12
|
+
Once a question is escalated, the later tier's valid answer is the result, even under
|
|
13
|
+
that tier's own threshold: it is returned and logged as low confidence (AI never
|
|
14
|
+
blocks, DESIGN 4.8; the caller decides what a low-confidence answer means). An earlier
|
|
15
|
+
tier's answer is kept only when no later tier gave one of the choices (it refused, or
|
|
16
|
+
said it cannot tell). Confidences are never compared across tiers: each model reports
|
|
17
|
+
its own, and a small model is often surer than it should be. With more tiers, the
|
|
18
|
+
latest tier that gave a valid answer wins. The thresholds and the tiers that may be
|
|
19
|
+
asked come from the profile's `decide` section (`DecideCfg`), per question-id prefix.
|
|
20
|
+
|
|
21
|
+
A backend may also answer `Deferred`: an out-of-process host answers the question
|
|
22
|
+
later (the model runs in the user's own agent session), and a later `decide()` call for
|
|
23
|
+
the same question picks the answer up.
|
|
24
|
+
|
|
25
|
+
Every step is appended to the decision log, `<state>/decisions.jsonl` under the state
|
|
26
|
+
backend (machine-local run state, not the human-auditable approvals of
|
|
27
|
+
`.chipgraph/decisions/`): question id, backend, tier, value, confidence, threshold,
|
|
28
|
+
model id when known, and duration.
|
|
29
|
+
|
|
30
|
+
Generic: no model, provider or harness is named here; backends live in the adapters.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import time
|
|
39
|
+
from collections.abc import Callable, Iterable
|
|
40
|
+
from datetime import UTC, datetime
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Literal, Protocol, Self, runtime_checkable
|
|
43
|
+
|
|
44
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
|
|
45
|
+
|
|
46
|
+
from chipgraph.core.config.models import DecideCfg, DecideTier
|
|
47
|
+
from chipgraph.core.contracts import DataLabel, Decision
|
|
48
|
+
from chipgraph.core.state.layout import StateLayout
|
|
49
|
+
|
|
50
|
+
DECISION_LOG_NAME = "decisions.jsonl"
|
|
51
|
+
"""File name of the decision log, directly under the state directory."""
|
|
52
|
+
|
|
53
|
+
_MAX_REASON_CHARS = 500
|
|
54
|
+
"""Longest model `reason` kept in the decision log; longer ones are cut."""
|
|
55
|
+
|
|
56
|
+
DecisionValue = str | int | float | bool
|
|
57
|
+
"""A decided value, as in the `Decision` contract."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DecideError(Exception):
|
|
61
|
+
"""`decide()` cannot run as asked: a bad rule result, or nothing that could answer."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Undecided(DecideError):
|
|
65
|
+
"""No rule answered and no model tier gave an answer that is one of the choices."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, message: str, *, question_id: str) -> None:
|
|
68
|
+
super().__init__(message)
|
|
69
|
+
self.question_id = question_id
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Question(BaseModel):
|
|
73
|
+
"""A multiple-choice question for `decide()`.
|
|
74
|
+
|
|
75
|
+
`choices` are the only acceptable answers. `context` is the text given to a model
|
|
76
|
+
with the question (a log excerpt, say); `labels` are the data labels of that
|
|
77
|
+
context, so a context labeled 'nda' never reaches a cloud model.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
81
|
+
|
|
82
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
83
|
+
id: str = Field(
|
|
84
|
+
min_length=1,
|
|
85
|
+
description="Stable id of the question, e.g. 'triage.<hash>'; prefixes pick overrides.",
|
|
86
|
+
)
|
|
87
|
+
prompt: str = Field(min_length=1, description="The question itself.")
|
|
88
|
+
kind: Literal["choice"] = Field(
|
|
89
|
+
default="choice", description="Answer kind: 'choice', exactly one of `choices`."
|
|
90
|
+
)
|
|
91
|
+
choices: tuple[str, ...] = Field(min_length=1, description="The acceptable answers.")
|
|
92
|
+
context: str = Field(default="", description="Text a model reads to answer the question.")
|
|
93
|
+
labels: tuple[DataLabel, ...] = Field(
|
|
94
|
+
default=(), description="Data labels of the context; 'nda' only goes to local models."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@model_validator(mode="after")
|
|
98
|
+
def _check_choices(self) -> Self:
|
|
99
|
+
if any(not c.strip() for c in self.choices):
|
|
100
|
+
raise ValueError("a choice must not be empty")
|
|
101
|
+
folded = [c.strip().casefold() for c in self.choices]
|
|
102
|
+
if len(set(folded)) != len(folded):
|
|
103
|
+
raise ValueError(f"choices must be distinct (ignoring case): {list(self.choices)}")
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def match(self, value: object) -> str | None:
|
|
107
|
+
"""The choice `value` names, or `None` if it names none.
|
|
108
|
+
|
|
109
|
+
An exact choice matches; otherwise a string equal to one choice ignoring case and
|
|
110
|
+
surrounding whitespace does (models write 'RTL' for 'rtl'). Non-strings never do.
|
|
111
|
+
"""
|
|
112
|
+
if not isinstance(value, str):
|
|
113
|
+
return None
|
|
114
|
+
if value in self.choices:
|
|
115
|
+
return value
|
|
116
|
+
folded = value.strip().casefold()
|
|
117
|
+
for choice in self.choices:
|
|
118
|
+
if choice.strip().casefold() == folded:
|
|
119
|
+
return choice
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def fingerprint(self) -> str:
|
|
123
|
+
"""A sha256 of the whole question: a recorded answer is only reused for the same one."""
|
|
124
|
+
return hashlib.sha256(self.model_dump_json().encode("utf-8")).hexdigest()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ModelAnswer(BaseModel):
|
|
128
|
+
"""What a model tier answered: a value (maybe not a valid choice) and its confidence."""
|
|
129
|
+
|
|
130
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
131
|
+
|
|
132
|
+
value: str | None = Field(description="The answer, or None when it could not be read.")
|
|
133
|
+
confidence: float = Field(ge=0, le=1, description="The model's confidence, 0 to 1.")
|
|
134
|
+
model: str | None = Field(default=None, description="The model id that answered, if known.")
|
|
135
|
+
reason: str = Field(default="", description="The model's short justification.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Deferred(BaseModel):
|
|
139
|
+
"""The question is queued: an out-of-process host answers it later.
|
|
140
|
+
|
|
141
|
+
Call `decide()` again for the same question once the host has answered.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
145
|
+
|
|
146
|
+
question_id: str = Field(description="The question waiting for an answer.")
|
|
147
|
+
tier: DecideTier = Field(description="The model tier the question waits for.")
|
|
148
|
+
model: str | None = Field(default=None, description="The model the host should use.")
|
|
149
|
+
message: str = Field(default="", description="What the caller should do next.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@runtime_checkable
|
|
153
|
+
class ModelBackend(Protocol):
|
|
154
|
+
"""Asks one model tier a `Question`: answers now, or defers to a host."""
|
|
155
|
+
|
|
156
|
+
name: str
|
|
157
|
+
|
|
158
|
+
async def ask(self, question: Question, tier: DecideTier) -> ModelAnswer | Deferred:
|
|
159
|
+
"""Ask `tier` the question; raise to refuse (e.g. 'nda' data for a cloud model)."""
|
|
160
|
+
...
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
Rule = Callable[[Question], Decision | None]
|
|
164
|
+
"""A deterministic answer: a `Decision` with `backend="rule"`, or `None` to pass."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def rule_decision(question: Question, value: str, confidence: float = 1.0) -> Decision:
|
|
168
|
+
"""The `Decision` a rule returns for `question`: `value`, from a rule."""
|
|
169
|
+
return Decision(question_id=question.id, value=value, confidence=confidence, backend="rule")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --- the decision log ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
DecisionEvent = Literal["decided", "escalated", "rejected", "deferred", "undecided", "error"]
|
|
175
|
+
"""One step of `decide()`:
|
|
176
|
+
|
|
177
|
+
- `decided`: the final answer (from a rule or a model tier); `low_confidence` when it
|
|
178
|
+
is under its tier's threshold, i.e. the latest valid answer, kept because no tier
|
|
179
|
+
was sure enough;
|
|
180
|
+
- `escalated`: a tier's answer was under its threshold or not one of the choices, and
|
|
181
|
+
the next tier is asked;
|
|
182
|
+
- `rejected`: the last tier's answer was under its threshold or not one of the
|
|
183
|
+
choices; a `decided` low-confidence entry follows with the answer kept (the last
|
|
184
|
+
tier's own if it was a choice, else the latest earlier valid one), or `undecided`;
|
|
185
|
+
- `deferred`: a tier will be answered later by a host;
|
|
186
|
+
- `undecided`: no valid answer at all;
|
|
187
|
+
- `error`: the backend raised (refused the question, the budget is spent ...).
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _now() -> datetime:
|
|
192
|
+
return datetime.now(UTC)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DecisionLogEntry(BaseModel):
|
|
196
|
+
"""One line of the decision log."""
|
|
197
|
+
|
|
198
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
199
|
+
|
|
200
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
|
|
201
|
+
ts: AwareDatetime = Field(default_factory=_now, description="When the step ended.")
|
|
202
|
+
question_id: str = Field(description="The question.")
|
|
203
|
+
event: DecisionEvent = Field(description="What this step was.")
|
|
204
|
+
backend: Literal["rule", "small", "large"] = Field(description="Who answered.")
|
|
205
|
+
tier: DecideTier | None = Field(default=None, description="The model tier; None for rules.")
|
|
206
|
+
value: DecisionValue | None = Field(
|
|
207
|
+
default=None, description="The answer as one of the choices, if it was one."
|
|
208
|
+
)
|
|
209
|
+
answer: str | None = Field(
|
|
210
|
+
default=None, description="The raw model answer, when it was not one of the choices."
|
|
211
|
+
)
|
|
212
|
+
confidence: float = Field(default=0.0, ge=0, le=1, description="Confidence of the answer.")
|
|
213
|
+
threshold: float | None = Field(
|
|
214
|
+
default=None, ge=0, le=1, description="The confidence the tier needed."
|
|
215
|
+
)
|
|
216
|
+
low_confidence: bool = Field(
|
|
217
|
+
default=False,
|
|
218
|
+
description=(
|
|
219
|
+
"A decision returned although under its threshold: the latest tier's valid "
|
|
220
|
+
"answer (confidences are never compared across tiers)."
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
model: str | None = Field(default=None, description="The model id, when known.")
|
|
224
|
+
duration_s: float = Field(ge=0, description="Seconds this step took.")
|
|
225
|
+
reason: str = Field(default="", description="The model's justification, cut short.")
|
|
226
|
+
error: str | None = Field(default=None, description="The error, for `event='error'`.")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def decision_log_path(layout: StateLayout) -> Path:
|
|
230
|
+
"""Where the decision log lives: `<root>/.chipgraph/state/decisions.jsonl`."""
|
|
231
|
+
return layout.state_dir / DECISION_LOG_NAME
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class DecisionLog:
|
|
235
|
+
"""The append-only JSONL log of every `decide()` step."""
|
|
236
|
+
|
|
237
|
+
def __init__(self, path: Path) -> None:
|
|
238
|
+
self.path = path
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def for_layout(cls, layout: StateLayout) -> DecisionLog:
|
|
242
|
+
"""The decision log of the project whose state is under `layout`."""
|
|
243
|
+
return cls(decision_log_path(layout))
|
|
244
|
+
|
|
245
|
+
def append(self, entry: DecisionLogEntry) -> None:
|
|
246
|
+
"""Append one entry, flushed and fsynced like the run journal."""
|
|
247
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
249
|
+
f.write(entry.model_dump_json() + "\n")
|
|
250
|
+
f.flush()
|
|
251
|
+
os.fsync(f.fileno())
|
|
252
|
+
|
|
253
|
+
def read(self) -> list[DecisionLogEntry]:
|
|
254
|
+
"""Every complete entry; a partial last line (a crash mid-write) is skipped."""
|
|
255
|
+
if not self.path.is_file():
|
|
256
|
+
return []
|
|
257
|
+
entries: list[DecisionLogEntry] = []
|
|
258
|
+
# The text after the last newline (if any) was never finished: skip it.
|
|
259
|
+
complete = self.path.read_text(encoding="utf-8").split("\n")[:-1]
|
|
260
|
+
for line_no, line in enumerate(complete, start=1):
|
|
261
|
+
if not line.strip():
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
entries.append(DecisionLogEntry.model_validate(json.loads(line)))
|
|
265
|
+
except ValueError as exc:
|
|
266
|
+
raise DecideError(f"decision log {self.path}: bad line {line_no}") from exc
|
|
267
|
+
return entries
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --- decide ----------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _cut(text: str) -> str:
|
|
274
|
+
return text if len(text) <= _MAX_REASON_CHARS else text[: _MAX_REASON_CHARS - 1] + "…"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _check_rule_decision(question: Question, rule: Rule, decision: Decision) -> None:
|
|
278
|
+
name = getattr(rule, "__name__", repr(rule))
|
|
279
|
+
if decision.question_id != question.id:
|
|
280
|
+
raise DecideError(
|
|
281
|
+
f"rule {name} answered question {decision.question_id!r}, not {question.id!r}"
|
|
282
|
+
)
|
|
283
|
+
if decision.backend != "rule":
|
|
284
|
+
raise DecideError(f"rule {name} returned a decision with backend {decision.backend!r}")
|
|
285
|
+
if not isinstance(decision.value, str) or decision.value not in question.choices:
|
|
286
|
+
raise DecideError(
|
|
287
|
+
f"rule {name} answered {decision.value!r} to {question.id!r}, which is not one "
|
|
288
|
+
f"of its choices {list(question.choices)}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def low_confidence(decision: Decision, cfg: DecideCfg) -> bool:
|
|
293
|
+
"""True when a model's `decision` is under its tier's threshold (rules never are)."""
|
|
294
|
+
if decision.backend == "rule":
|
|
295
|
+
return False
|
|
296
|
+
tier: DecideTier = "small" if decision.backend == "small" else "large"
|
|
297
|
+
return decision.confidence < cfg.for_question(decision.question_id).threshold(tier)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def decide(
|
|
301
|
+
question: Question,
|
|
302
|
+
*,
|
|
303
|
+
rules: Iterable[Rule] = (),
|
|
304
|
+
backend: ModelBackend | None = None,
|
|
305
|
+
cfg: DecideCfg | None = None,
|
|
306
|
+
log: DecisionLog,
|
|
307
|
+
) -> Decision | Deferred:
|
|
308
|
+
"""Answer `question`: rules, then the small model tier, then the large one.
|
|
309
|
+
|
|
310
|
+
The first answer at or over its tier's threshold is final. Otherwise the result is
|
|
311
|
+
the valid answer of the latest tier that gave one, low confidence (an escalated
|
|
312
|
+
tier's answer replaces an earlier tier's, whatever their confidences).
|
|
313
|
+
|
|
314
|
+
Returns the `Decision`, or `Deferred` when the backend queued the question for a
|
|
315
|
+
host (call again later). Raises `Undecided` when no rule answered and no tier gave
|
|
316
|
+
one of the choices, `DecideError` when a rule misbehaves or nothing can answer, and
|
|
317
|
+
whatever the backend raises (e.g. a refusal of 'nda' data), after logging it.
|
|
318
|
+
"""
|
|
319
|
+
cfg = cfg if cfg is not None else DecideCfg()
|
|
320
|
+
|
|
321
|
+
for rule in rules:
|
|
322
|
+
start = time.monotonic()
|
|
323
|
+
decision = rule(question)
|
|
324
|
+
if decision is None:
|
|
325
|
+
continue
|
|
326
|
+
_check_rule_decision(question, rule, decision)
|
|
327
|
+
log.append(
|
|
328
|
+
DecisionLogEntry(
|
|
329
|
+
question_id=question.id,
|
|
330
|
+
event="decided",
|
|
331
|
+
backend="rule",
|
|
332
|
+
value=decision.value,
|
|
333
|
+
confidence=decision.confidence,
|
|
334
|
+
duration_s=time.monotonic() - start,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
return decision
|
|
338
|
+
|
|
339
|
+
settings = cfg.for_question(question.id)
|
|
340
|
+
tiers = settings.enabled_tiers
|
|
341
|
+
if not tiers:
|
|
342
|
+
raise DecideError(
|
|
343
|
+
f"no rule answered {question.id!r} and every model tier is disabled "
|
|
344
|
+
"(decide.enabled_tiers)"
|
|
345
|
+
)
|
|
346
|
+
if backend is None:
|
|
347
|
+
raise DecideError(f"no rule answered {question.id!r} and no model backend was given")
|
|
348
|
+
|
|
349
|
+
begin = time.monotonic()
|
|
350
|
+
# The latest tier's valid answer under its threshold: what is returned if no tier
|
|
351
|
+
# is sure enough. A later tier's valid answer always replaces an earlier one; the
|
|
352
|
+
# confidences of different models are not comparable.
|
|
353
|
+
latest: tuple[Decision, DecisionLogEntry] | None = None
|
|
354
|
+
for index, tier in enumerate(tiers):
|
|
355
|
+
start = time.monotonic()
|
|
356
|
+
threshold = settings.threshold(tier)
|
|
357
|
+
try:
|
|
358
|
+
answer = await backend.ask(question, tier)
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
log.append(
|
|
361
|
+
DecisionLogEntry(
|
|
362
|
+
question_id=question.id,
|
|
363
|
+
event="error",
|
|
364
|
+
backend=tier,
|
|
365
|
+
tier=tier,
|
|
366
|
+
threshold=threshold,
|
|
367
|
+
duration_s=time.monotonic() - start,
|
|
368
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
raise
|
|
372
|
+
duration = time.monotonic() - start
|
|
373
|
+
|
|
374
|
+
if isinstance(answer, Deferred):
|
|
375
|
+
log.append(
|
|
376
|
+
DecisionLogEntry(
|
|
377
|
+
question_id=question.id,
|
|
378
|
+
event="deferred",
|
|
379
|
+
backend=tier,
|
|
380
|
+
tier=tier,
|
|
381
|
+
threshold=threshold,
|
|
382
|
+
model=answer.model,
|
|
383
|
+
duration_s=duration,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
return answer
|
|
387
|
+
|
|
388
|
+
value = question.match(answer.value)
|
|
389
|
+
confidence = answer.confidence if value is not None else 0.0
|
|
390
|
+
step = DecisionLogEntry(
|
|
391
|
+
question_id=question.id,
|
|
392
|
+
event="escalated",
|
|
393
|
+
backend=tier,
|
|
394
|
+
tier=tier,
|
|
395
|
+
value=value,
|
|
396
|
+
answer=answer.value if value is None else None,
|
|
397
|
+
confidence=confidence,
|
|
398
|
+
threshold=threshold,
|
|
399
|
+
model=answer.model,
|
|
400
|
+
duration_s=duration,
|
|
401
|
+
reason=_cut(answer.reason),
|
|
402
|
+
)
|
|
403
|
+
if value is not None:
|
|
404
|
+
candidate = Decision(
|
|
405
|
+
question_id=question.id, value=value, confidence=confidence, backend=tier
|
|
406
|
+
)
|
|
407
|
+
if confidence >= threshold:
|
|
408
|
+
log.append(step.model_copy(update={"event": "decided"}))
|
|
409
|
+
return candidate
|
|
410
|
+
latest = (candidate, step)
|
|
411
|
+
|
|
412
|
+
if index == len(tiers) - 1:
|
|
413
|
+
step = step.model_copy(update={"event": "rejected"})
|
|
414
|
+
log.append(step)
|
|
415
|
+
|
|
416
|
+
if latest is None:
|
|
417
|
+
log.append(
|
|
418
|
+
DecisionLogEntry(
|
|
419
|
+
question_id=question.id,
|
|
420
|
+
event="undecided",
|
|
421
|
+
backend=tiers[-1],
|
|
422
|
+
tier=tiers[-1],
|
|
423
|
+
duration_s=time.monotonic() - begin,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
raise Undecided(
|
|
427
|
+
f"no model tier ({', '.join(tiers)}) answered {question.id!r} with one of "
|
|
428
|
+
f"its choices {list(question.choices)}",
|
|
429
|
+
question_id=question.id,
|
|
430
|
+
)
|
|
431
|
+
decision, answered = latest
|
|
432
|
+
log.append(
|
|
433
|
+
answered.model_copy(
|
|
434
|
+
update={
|
|
435
|
+
"event": "decided",
|
|
436
|
+
"low_confidence": True,
|
|
437
|
+
"ts": _now(),
|
|
438
|
+
"duration_s": time.monotonic() - begin,
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
return decision
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
__all__ = [
|
|
446
|
+
"DECISION_LOG_NAME",
|
|
447
|
+
"DecideError",
|
|
448
|
+
"DecisionEvent",
|
|
449
|
+
"DecisionLog",
|
|
450
|
+
"DecisionLogEntry",
|
|
451
|
+
"DecisionValue",
|
|
452
|
+
"Deferred",
|
|
453
|
+
"ModelAnswer",
|
|
454
|
+
"ModelBackend",
|
|
455
|
+
"Question",
|
|
456
|
+
"Rule",
|
|
457
|
+
"Undecided",
|
|
458
|
+
"decide",
|
|
459
|
+
"decision_log_path",
|
|
460
|
+
"low_confidence",
|
|
461
|
+
"rule_decision",
|
|
462
|
+
]
|