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