Skip to content

Commit 4b88157

Browse files
committed
Merge branch 'stroland02/m1-nodes'
2 parents bda97ed + e3ab006 commit 4b88157

6 files changed

Lines changed: 824 additions & 20 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# Wiring the third vendor, and the tuple that meant three things
2+
3+
M3-W88 built Cloudflare as a third deprecation source, proved the parser reads its page, and
4+
reported that nothing ran it: `DEPRECATION_SOURCES` was a tuple in `src/sync/cli.py`, so a source
5+
added to the adapter module was importable, tested and unreachable. That is the same defect
6+
`sync.signals.registry` was written to fix for vendor adapters, where `cli.py` named
7+
`StripeAdapter` by hand and no run could reach the second one.
8+
9+
The tuple had a second problem. Four call sites read it, doing three different jobs, and its
10+
comment asserted a property of the pages that was false. This task splits the question the four
11+
sites ask and moves the registry out of the entry point.
12+
13+
## What `parse_parameter_deprecations` does with a page carrying no parameter table
14+
15+
**It returns the empty list. No raise, no partial row, nothing written.**
16+
17+
Established by running it over all three committed captures before anything was designed:
18+
19+
```
20+
anthropic.md: 3 parameter rows
21+
openai.md: 0 parameter rows
22+
cloudflare-workers-ai.md: 0 parameter rows
23+
```
24+
25+
Then by asking *why*, because "returns nothing" and "cannot read this page" are the distinction
26+
the whole signal is built on:
27+
28+
- **Cloudflare** has no pipe table anywhere on the page, so `_cells` rejects every line.
29+
- **OpenAI** has pipe tables, and eight of them carry a `Deprecated model` header cell that
30+
`_STATUS` does match on. What stops them is the next filter: `_parameters_in` requires
31+
`^[A-Za-z_][A-Za-z0-9_]*$`, and `Shutdown date` has a space. So the rows are dropped one step
32+
later than expected rather than not matching at all.
33+
34+
The stronger finding is that this is a fact about what the vendors publish, not about the parser:
35+
**the word "parameter" does not occur anywhere on the OpenAI page**, and does not occur anywhere on
36+
Cloudflare's. Anthropic's page carries an `## API parameter deprecations` section with a real
37+
table. Only one of the three publishes parameter deprecations at all.
38+
39+
That measurement contradicts the comment this task replaced, which claimed *both* existing vendors
40+
publish a parameter table. It was false for OpenAI before Cloudflare existed, and nobody had
41+
checked.
42+
43+
### So the two failure modes are not symmetric, and the design follows that
44+
45+
The brief posed them as opposites. They are not equally bad, because the two parsers fail
46+
differently:
47+
48+
| Wrong wiring | What happens | Severity |
49+
|---|---|---|
50+
| A model-publishing source left out of the model scan | Its retirements are never seen. The vendor looks healthy. Eighteen Workers AI models, silently. | **Severe, and silent** |
51+
| A source with no parameter table included in the parameter scan | `parse_parameter_deprecations` returns `[]`. Nothing incorrect is produced and no extra page is downloaded, because both halves share one cache file. | **Benign** |
52+
| A source with no model table included in the model scan | `DeprecationAdapter.fetch_changes` raises on zero rows, and the scan prints `model-deprecation: <vendor> unavailable` on every run. | **Loud, and self-reporting** |
53+
54+
The residual cost on the parameter side is not a bad row. It is the failure path: a fetch error
55+
prints `parameter-deprecation: <vendor> page unavailable`, which claims a detector lost findings
56+
for a vendor that publishes none. `_scan` prints a per-detector count including zero precisely so
57+
that a zero means something, and a zero taken across three vendors of which two cannot contribute
58+
means less, not more.
59+
60+
So the declaration earns its keep mainly on the model side and on not misreporting the parameter
61+
side. It is not preventing corrupt data, and this report says so rather than overstating it.
62+
63+
## The design, and the two that were rejected
64+
65+
**Chosen: two required fields on `DeprecationSource`, and the registry moved beside the constants
66+
it names.**
67+
68+
```python
69+
publishes_model_deprecations: bool
70+
publishes_parameter_deprecations: bool
71+
```
72+
73+
`DEPRECATION_SOURCES` now lives in `sync/signals/deprecations/adapter.py` with the three source
74+
constants, exported through the package, and is read by `cli.py` rather than defined there. Two
75+
accessors derive the subsets:
76+
77+
```python
78+
def model_deprecation_sources() -> tuple[DeprecationSource, ...]
79+
def parameter_deprecation_sources() -> tuple[DeprecationSource, ...]
80+
```
81+
82+
Three properties decided it.
83+
84+
**One list, so the classic drift cannot happen.** A vendor is registered in exactly one place. The
85+
failure the brief warned about — added to one list, forgotten in the other — has no site to occur
86+
at.
87+
88+
**Both fields are required, with no default.** A default is how a fourth source silently inherits
89+
whichever answer was right for the vendor that happened to be added first, which is this defect
90+
with a new coat.
91+
92+
**Booleans rather than a set of signal names.** `CLAUDE.md` says not to add validation for
93+
conditions that cannot occur; the better move is to make the condition unable to occur.
94+
`signals=frozenset({"parameters"})` — plural typo — would silently drop a source out of the
95+
parameter scan and need a validator to catch. A misspelled dataclass keyword is a `TypeError` at
96+
import, with no validation code written.
97+
98+
**Rejected: two lists.** It duplicates every vendor that carries both signals, so a fourth vendor
99+
gets added to one and forgotten in the other. That is the failure this task exists to fix, moved
100+
rather than removed. It is also worse than the brief suggests: the literal indexer needs the
101+
*union*, so two lists means a third expression at the third call site, and nothing keeps that one
102+
in step either.
103+
104+
**Rejected: ask the parser.** Derive it — a page carries parameter deprecations if parsing finds
105+
some. It needs no configuration and cannot go stale, and it is wrong for the reason the rest of
106+
this system keeps insisting on: it cannot distinguish "this vendor publishes none" from "the parser
107+
could not read this page". `DeprecationAdapter` already refuses to make exactly that inference,
108+
raising on a page that parses to zero rows rather than reporting an empty change list. Deriving the
109+
declaration would also invert the order of operations — the page has to be fetched to decide
110+
whether to fetch it.
111+
112+
The declared answer is held to the evidence instead, which is the pattern `prefixes` already
113+
uses in this package: authored beside the URL, and checked by a test against the committed capture.
114+
`test_only_the_anthropic_page_publishes_a_parameter_table` is that check.
115+
116+
### The declaration for OpenAI is a behaviour change, and it is worth naming
117+
118+
`OPENAI.publishes_parameter_deprecations` is `False`, so its page is no longer read for
119+
parameters. Today that changes no output — the parse returned `[]`and it costs no extra
120+
download, because the model half fetches the page either way.
121+
122+
The risk is honest and stated: if OpenAI adds a parameter table, the declaration is stale and the
123+
signal is missed. That is the same staleness `prefixes` already carries — a new model family with a
124+
new prefix goes unindexed — with the same mitigation, a committed capture and a test over it. What
125+
tipped the decision is that a declaration meaning "may one day carry" is uncheckable, and one
126+
meaning "does carry, as measured" is.
127+
128+
## What each of the four call sites now asks for
129+
130+
| Line | Site | Reads | Today |
131+
|---|---|---|---|
132+
| 525 | `_parameter_deprecations` | `parameter_deprecation_sources()` — which pages carry a request-parameter table | Anthropic |
133+
| 627 | `_model_deprecations` | `model_deprecation_sources()` — which pages carry model retirements | all three |
134+
| 674 | `_literal_call_sites` | `DEPRECATION_SOURCES`**every** source, unfiltered | all three |
135+
| 870 | the run report | `model_deprecation_sources()` — the same set line 627 read | all three |
136+
137+
Line 674 is the one the brief flagged as easily overlooked, and it is the one that changed least.
138+
It supplies `prefixes` to the literal indexer, which indexes model ids in the *customer's* code and
139+
has nothing to do with which table a vendor publishes. A finding of either kind needs a call site
140+
to attach to, so narrowing it to one signal's sources would leave the other signal's findings
141+
pointing at nothing. It stays unfiltered, and the docstring now says that is deliberate.
142+
143+
Lines 627 and 867 must name the same set`VendorChangeDetector` is scoped to one vendor, so a
144+
retirement upserted for a vendor with no detector is a row nothing will ever read. They read one
145+
shared accessor rather than two comprehensions, so they cannot drift.
146+
147+
## Is a fourth vendor added to one place and missed in another detectable?
148+
149+
**The two-list drift cannot occur**: there is one list, and one field per signal on each entry.
150+
151+
**The residual gap is real and is caught.** A `DeprecationSource` can still be defined in
152+
`adapter.py` and never added to `DEPRECATION_SOURCES` — which is exactly what happened to
153+
`CLOUDFLARE`. `test_every_source_the_deprecations_package_defines_is_registered_for_a_scan` walks
154+
the adapter module's namespace for `DeprecationSource` instances and asserts the set matches the
155+
registry, and asserts the set is non-empty first so it cannot pass over nothing.
156+
157+
Two further couplings are asserted rather than conventional:
158+
159+
- `test_the_run_builds_a_detector_for_every_vendor_it_fetched_retirements_for` compares the run
160+
report's vendor list against the vendors `_model_deprecations` actually produced changes for,
161+
rather than against a literal list. A fourth source needs no edit to this test.
162+
- `test_no_source_is_registered_twice` — every row is keyed by vendor id, so a repeated source
163+
would parse one page twice and upsert under one key.
164+
165+
The one gap left: a source defined in some *other* module would not be seen by the namespace walk.
166+
The three constants live in `adapter.py` by convention and the test asserts against that module.
167+
168+
## The exact wording replaced at `cli.py:93`
169+
170+
Removed, in full:
171+
172+
```python
173+
# Vendors whose parameter deprecations a scan reads. Both publish one page carrying both a model
174+
# lifecycle table and a parameter table; `parse_parameter_deprecations` tells them apart.
175+
DEPRECATION_SOURCES: tuple[DeprecationSource, ...] = (ANTHROPIC, OPENAI)
176+
```
177+
178+
Three claims in two lines, and two of them were false. "Both publish one page carrying both a
179+
model lifecycle table and a parameter table" is false for OpenAI, whose page never mentions
180+
parameters. "`parse_parameter_deprecations` tells them apart" credits the wrong rule: what keeps
181+
Anthropic's lifecycle rows out of the parameter results is not the status cell — `_STATUS` matches
182+
a bare `Deprecated` — but `_IDENTIFIER` rejecting a hyphenated model id one filter later.
183+
184+
Nothing replaced it in `cli.py`. The definition moved to `adapter.py`, and `cli.py` imports it.
185+
186+
## Mutation results
187+
188+
Ten mutations plus a sentinel, each applied to the shipped source and reverted. Every one kills at
189+
least one test, and the restored baseline is re-asserted green afterwards so that "nothing failed"
190+
is distinguishable from "cannot see failures".
191+
192+
| # | Mutation | Result |
193+
|---|---|---|
194+
|| **Sentinel**: no source is registered at all | killed 20 — a kill is detectable |
195+
| M1 | Drop the third vendor: restore the shipped defect | killed 5 |
196+
| M2 | Parameter scan ignores the declaration and reads every source | killed 1 |
197+
| M3 | Model scan ignores the declaration and reads every source | killed 2 |
198+
| M4 | OpenAI declares a parameter table it does not publish | killed 1 |
199+
| M5 | Cloudflare declares a parameter table it does not publish | killed 1 |
200+
| M6 | Cloudflare declares no model retirements | killed 3 |
201+
| M7 | Literal indexer narrowed to one signal's sources | killed 1 |
202+
| M8 | Run report names the parameter sources instead of the model sources | killed 2 |
203+
| M9 | Run report drops the filter entirely | killed 1 |
204+
205+
M1 is the shipped defect restored, and it kills five tests including the registration invariant and
206+
the literal-prefix check — the two halves W88 could reach and could not.
207+
208+
### A third way to get a false survival
209+
210+
`CLAUDE.md` and W88 record two harness faults that both report *every mutation survives*: a plugin
211+
flag colliding with `-n auto` so pytest exits 4 with no `FAILED` lines, and parsing
212+
`startswith("FAILED ")` against colourised output. This run found a third.
213+
214+
**M4's first form did not compile.** Written as an insertion, it produced a duplicate
215+
`publishes_parameter_deprecations` keyword — `SyntaxError: keyword argument repeated`. pytest
216+
reports that as `ERROR tests/...` rather than `FAILED tests/...`, **and still exits 1**. So it
217+
lands inside the accepted `{0, 1}` exit codes, matches no `FAILED ` prefix, and reads as a clean
218+
survival. Verified by hand before it was believed.
219+
220+
The general rule worth keeping: a harness must separate **killed** from **did not compile** from
221+
**cannot see the result**, because two of those three look like a survival and only one is. The
222+
harness now counts `ERROR ` lines and reports them as its own fault, never as a survival.
223+
224+
### M9 survived first, and the test was at fault
225+
226+
M9 removes the filter from the run report. It survived the first run, and following
227+
`CLAUDE.md`'s order — suspect the mutation, then the test, then the code — the mutation was
228+
legitimate and the fault was in the test.
229+
230+
`cli.py` imports `DEPRECATION_SOURCES` by name, so it holds **its own binding**. The test
231+
registered a synthetic parameters-only source by patching `adapter.DEPRECATION_SOURCES`, which the
232+
accessors read at call time — but the mutated line 870 read `cli.DEPRECATION_SOURCES`, still
233+
pointing at the shipped three. An unfiltered report over that stale binding returns exactly the
234+
set the test asserted, so the mutation changed nothing the assertion could see. Patching both
235+
bindings kills it.
236+
237+
For the record, the production code was never the suspect that paid out. It has now been outside
238+
the fault on this project every time.
239+
240+
### What the shipped set cannot exercise
241+
242+
`publishes_model_deprecations` is `True` for all three vendors, so its `False` branch has no
243+
shipped example — the same "all N pages agree" coincidence that made two of the parser's rules
244+
facts about two pages rather than about deprecation pages.
245+
`test_a_source_publishing_only_parameters_reaches_only_the_parameter_scan` registers a synthetic
246+
source to exercise it, which is what M3 and M6 kill. Without it the field would be decoration.
247+
248+
## Gates
249+
250+
Run on the final tree, merged up to `origin/main`, unpiped, exit codes checked.
251+
252+
| Gate | Result | Exit |
253+
|---|---|---|
254+
| `uv run pytest -q` | 2237 passed, 2 skipped | 0 |
255+
| `uv run python scripts/lint_encoding.py src scripts tests` | clean | 0 |
256+
| `PYTHONIOENCODING=utf-8 uv run lint-imports` | 1 contract kept, 0 broken | 0 |
257+
| `uv run python scripts/lint_dead_links.py src --baseline scripts/dead_links_baseline.txt` | clean | 0 |
258+
259+
## What the next task should take
260+
261+
1. **`sync.index` is not told which signal a prefix serves, and that is now a choice rather than
262+
an oversight.** `_literal_call_sites` hands every source's prefixes to the literal indexer
263+
because a finding of either kind needs a call site. If a future source publishes parameter
264+
deprecations for models it does not name — a vendor documenting `temperature` without a
265+
retirement list — its prefixes would be indexed for a signal that has no use for them. Harmless
266+
today, and `src/sync/index/` was outside this task's files, so it is reported rather than
267+
changed.
268+
2. **A parameter table whose model ids are valid identifiers would be misread.** What keeps
269+
Anthropic's lifecycle rows out of the parameter parser is `_IDENTIFIER` rejecting a hyphen, not
270+
any rule about what a lifecycle row is. A vendor naming models `gpt5` or `opus` would emit one
271+
parameter deprecation per model. No committed page does this; the safety is incidental, exactly
272+
as W88 found for "Variants that remain active".
273+
3. **`adapter.py`'s module docstring still says "Both vendors serve clean markdown when the
274+
documented `.md` suffix is appended"**. There are three sources, and Cloudflare needs the
275+
directory-plus-`index.md` form because the bare `.md` suffix 404s there. W88 recorded the fact
276+
and the docstring was not updated; it was left alone here rather than edited around the
277+
registry addition.

docs/superpowers/specs/2026-07-28-sync-deprecation-signal.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ system refuses to make.
138138
|---|---|
139139
| Done | Catalogue, adapter with cache, literal index, migration rules, tier-0 remediator, tiering, `TieredRemediator` in the CLI, parameter deprecations end to end, and `DeprecationAdapter` constructed in the CLI — which is what turns a retired model into a `VendorChange` the tier-0 swap can repair |
140140
| Done | A third vendor. Cloudflare publishes Workers AI retirements as a bulleted list under a dated heading and names models `@cf/meta/llama-3.1-8b-instruct`, so two rules were facts about the first two pages rather than about deprecation pages: the pipe-table row shape, and "a model id has no path separator". Seven further rules the third page never exercises are recorded as untested rather than as confirmed. `docs/superpowers/reports/2026-07-29-third-deprecation-vendor.md` measures each one |
141-
| Next | Wiring it. `DEPRECATION_SOURCES` is a tuple in `cli.py`, so a source added to the adapter module is importable and tested but reaches no scan — the same defect `sync.signals.registry` was written to fix for adapters. It also feeds the parameter parser and the model parser from one list, which Cloudflare breaks: its page carries retirements and no parameter table |
141+
| Done | Wiring it. `DEPRECATION_SOURCES` has left `cli.py` for the adapter module, beside the constants it names, and each `DeprecationSource` now declares which signals its page carries. The four call sites that read one tuple ask three different questions: the parameter scan takes the sources publishing a parameter table, the model scan and the run report share one accessor over the sources publishing retirements, and the literal indexer takes every source unfiltered, because it indexes model ids in customer code and a finding of either kind needs a call site. Measured on the way: only Anthropic of the three publishes a parameter table — the word "parameter" appears nowhere on the OpenAI page — so the replaced comment was false for OpenAI before Cloudflare existed. `docs/superpowers/reports/2026-07-29-wiring-the-third-vendor.md` carries the design, the two rejected shapes and the mutation table |
142+
| Next | A state that is published as prose. "Variants that remain active" is real lifecycle information the parser cannot read; nothing depends on it today because that heading carries no date, so the safety is incidental rather than designed. A fourth vendor should then be chosen for a shape none of these three use — an HTML-only page, a JSON or YAML feed, or a page with no dates at all |
142143

143144
## Verification
144145

0 commit comments

Comments
 (0)