Skip to content

Commit 93cc4bb

Browse files
Add cross-spec suites (dow suite) for the check x model x domain x temp matrix
`dow aggregate` runs a project's N-way aggregators over a cohort within a SINGLE spec. A robustness sweep, though, is often a matrix where each cell is its own spec. `dow suite` aggregates versions drawn from SEVERAL specs at once, declared in a manifest `specs/<name>.suite.yaml` (its `specs:` list plus the project's own `evaluation.aggregators`/`plots`). dow still ships none of the coefficients or the plotting library - the suite only wires in the project's callables. - spec.py: SuiteSpec dataclass (load/from_dict/to_dict/fingerprint; kind: suite). - evaluators.py: CohortContext.specs - a parallel per-member spec-name list so an aggregator can bucket by spec/model/domain/temperature (empty for single-spec). - service.py: load_suite/list_suites/select_suite (all|latest|<tag>)/aggregate_suite /suite_aggregations/get_suite_aggregation; spec_files() excludes *.suite.yaml so a manifest never trips single-spec resolution; composite spec:version member ids. - store.py: _suites/<name>/ bundle namespace under index["suites"] (kept out of list_specs); save/list/get_suite_aggregation guard the suite name with _safe_component on read AND write, and use the atomic writers. - cli.py: `dow suite` command (+ fix the CLI-local _spec_files() to also exclude *.suite.yaml, so commit/history/compare/... auto-resolution ignores manifests). - report.py: print_aggregation is suite-aware (title + N-specs header). - mcp_server.py: dow_suite tool (server now exposes 15 tools). - docs/suite.txt (+ regenerated man/dow.1); README/PYPI_README/PROJECT_PLAN. - tests: test_suite.py (11 - cross-spec select all/latest/tag, bucket-by-spec, persistence, manifest-not-a-spec, error capture, empty/missing-agg guards, CLI resolution) + a suite-name traversal test + dow_suite in the MCP tool set. Bumps dow to 2.1.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent eb97263 commit 93cc4bb

17 files changed

Lines changed: 895 additions & 30 deletions

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,47 @@ All notable changes to dow are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/), and dow adheres to
55
[Semantic Versioning](https://semver.org/).
66

7+
## [2.1.0] - 2026-07-11
8+
9+
### Added
10+
- **Cross-spec suites (`dow suite`) — aggregate versions across *several* specs
11+
(the check × model × domain × temperature matrix).** `dow aggregate` runs a
12+
project's N-way aggregators over a cohort within a *single* spec, but a
13+
robustness sweep is often a matrix where each cell is its own spec. A suite is
14+
declared in a manifest, `specs/<name>.suite.yaml`, listing the participating
15+
`specs:` plus the project's own `evaluation.aggregators`/`plots`:
16+
17+
```yaml
18+
name: robustness_matrix
19+
specs: [check_llama, check_qwen, check_mistral]
20+
select: all # all | latest | <tag>
21+
evaluation:
22+
aggregators: [suite_metrics.py:agg_matrix]
23+
plots: [suite_plots.py:plot_matrix]
24+
```
25+
26+
`select` chooses the cohort: `all` (every version of each listed spec — the
27+
full matrix), `latest` (each spec's newest version), or a tag name (each
28+
spec's versions carrying that tag). Each member keeps its own captured config,
29+
and the `CohortContext` gains a parallel `specs` list naming each member's
30+
spec, so an aggregator can bucket by spec / model / domain / temperature.
31+
Member ids are composite `spec:version`. Suite runs persist as durable,
32+
git-tracked bundles under `.dow/aggregations/_suites/<name>/` — a **separate
33+
namespace** from single-spec aggregations — so a manifest never leaks into
34+
`dow history` or single-spec resolution. `dow suite --list`, `--show <id>`,
35+
and `--plot` mirror `dow aggregate`. The `.suite.yaml` suffix keeps manifests
36+
out of the inference-spec code paths (`spec_files` in both the service core and
37+
the CLI now exclude it). dow still ships **none** of the coefficients or the
38+
plotting library — the suite only wires in the project's callables.
39+
- **`dow_suite` MCP tool** (the server now exposes **15** tools), plus the
40+
`suite` command in the generated man page and `dow help suite`.
41+
42+
### Security
43+
- `Store.save_suite_aggregation` / `get_suite_aggregation` validate the suite
44+
name (and aggregation id) with `_safe_component` on both the write and read
45+
paths, matching the 2.0.4 store-write hardening; regression test added
46+
(`tests/test_security.py::test_store_writes_block_traversal_suite_name`).
47+
748
## [2.0.4] - 2026-07-11
849

950
### Security

PROJECT_PLAN.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,22 @@ An aggregator receives a `CohortContext` whose `members` is one `EvalContext` pe
326326

327327
`dow aggregate` selects the cohort — an explicit list of versions, every version carrying a `--tag`, or the whole history — runs the aggregators, and **persists the result as a durable, git-tracked bundle** under `.dow/aggregations/` (recording the member ids, their fingerprints, the coefficient values, any figures, and a timestamp). A robustness check therefore becomes a citable, reproducible object; `dow aggregate --list` and `--show <id>` retrieve past bundles. Aggregator failures are captured and reported, never fatal.
328328

329+
### Cross-spec suites (the matrix)
330+
331+
`dow aggregate` operates within a **single** spec. A robustness sweep, though, is frequently a **matrix** — the same check run across several models, domains, or temperatures, where each cell of the matrix is its own spec. **`dow suite`** aggregates versions drawn from **several** specs at once, so the whole matrix collapses into one citable object. The participating specs and the project's own aggregators/plots are declared in a manifest, `specs/<name>.suite.yaml` (a distinct suffix that keeps suites out of the inference-spec code paths, so a manifest never leaks into `dow history` or single-spec resolution):
332+
333+
```yaml
334+
# specs/robustness_matrix.suite.yaml
335+
name: robustness_matrix
336+
specs: [check_llama, check_qwen, check_mistral] # specs to draw versions from
337+
select: all # all | latest | <tag>
338+
evaluation:
339+
aggregators: [suite_metrics.py:agg_matrix] # your callables; dow ships none
340+
plots: [suite_plots.py:plot_matrix]
341+
```
342+
343+
Every member keeps its own captured `config`, and the `CohortContext` gains a parallel **`specs`** list naming each member's spec, so a suite aggregator can bucket by spec / model / domain / temperature. Member ids are composite `spec:version`. `select` chooses the cohort: `all` (every version of each listed spec — the full matrix), `latest` (each spec's newest version), or a tag name (each spec's versions carrying that tag; specs with no match are skipped, and an entirely empty selection is a clear error). Suite runs persist as durable, git-tracked bundles under `.dow/aggregations/_suites/<name>/` — a **separate namespace** from single-spec aggregations — and `dow suite --list`, `--show <id>`, and `--plot` mirror `dow aggregate`. The suite reuses the same aggregator/plot contract, so **dow still ships none of the coefficients or plotting code**.
344+
329345
### Pluggable plots (figures as content-addressed artifacts)
330346

331347
dow can turn any of these results into a figure without shipping a plotting library. The project references its own plot functions under `evaluation.plots`; each receives a `PlotContext` carrying the analysis `results` to render and a dow-provided `out_dir` to write into, and returns the path(s) of the figure file(s) it produced:
@@ -355,6 +371,7 @@ The commands are task-oriented, not version-control plumbing. Versioning is auto
355371
| `dow tag <label> [version]` | Tag a version with a free-form label: good, golden, baseline, bad, ... |
356372
| `dow eval [version]` | Run custom evaluators; compare scores against the previous and last-good versions |
357373
| `dow aggregate [VERSIONS]` | Aggregate reliability metrics over a cohort of versions (N-way); persist a git-tracked bundle, optionally with figures (`--plot`) |
374+
| `dow suite [NAME]` | Aggregate versions across several specs (a cross-spec matrix declared in `specs/<name>.suite.yaml`); persist a git-tracked bundle, optionally with figures (`--plot`) |
358375
| `dow tree` | Visualize evolution: a vertical trunk with branches, as a terminal tree or an exported Mermaid `gitGraph` |
359376

360377
Versions are referred to by simple names (`v1`, `v2`), the shortcuts `last` and `prev`, or any label applied with `dow tag` (for example `good`). They form a tree: every commit records its parent, and `dow commit --from v1` starts a new branch from an earlier version. Command output is rendered in the terminal.
@@ -410,7 +427,7 @@ The trunk `v1 -> v2` is the main line; `v3-branch` forks from v1 and continues t
410427

411428
### Programmatic and MCP surface
412429

413-
The command line is one front end over a headless core (`dow/service.py`); an AI agent gets the same workflow over the Model Context Protocol. The `dow-mcp` server (install with the `mcp` extra) exposes fourteen tools mirroring the commands above - scaffold, read/write a spec, commit, compare, explain, eval, aggregate, tag, history, inspect, tree, and docs - plus read-only resources (`dow://overview`, `dow://docs/<command>`, `dow://specs`, `dow://spec/<name>`) an agent can attach as context. Because both surfaces call the same core, they never drift apart, and both are data-structure agnostic: when a spec sets `embedding_model: none`, the built-in drift, stability, and verdict come back null (`driftEnabled` is false) and the configuration diff plus the project's own metrics carry the analysis. dow itself still ships no metric, statistic, or plotting code.
430+
The command line is one front end over a headless core (`dow/service.py`); an AI agent gets the same workflow over the Model Context Protocol. The `dow-mcp` server (install with the `mcp` extra) exposes fifteen tools mirroring the commands above - scaffold, read/write a spec, commit, compare, explain, eval, aggregate, aggregate across specs (suite), tag, history, inspect, tree, and docs - plus read-only resources (`dow://overview`, `dow://docs/<command>`, `dow://specs`, `dow://spec/<name>`) an agent can attach as context. Because both surfaces call the same core, they never drift apart, and both are data-structure agnostic: when a spec sets `embedding_model: none`, the built-in drift, stability, and verdict come back null (`driftEnabled` is false) and the configuration diff plus the project's own metrics carry the analysis. dow itself still ships no metric, statistic, or plotting code.
414431

415432
---
416433

@@ -513,7 +530,7 @@ The tool runs entirely offline by default (mock provider and a built-in hashing
513530
- [ ] `dow explain` attributes a behavioral change to the configuration difference and flags confounded comparisons.
514531
- [ ] `dow tree` visualizes the version evolution (terminal tree and exported Mermaid), with branches via `dow commit --from`.
515532
- [ ] Users can plug in custom evaluators (`evaluation.metrics`); `dow eval` runs them lazily, saves the scores with the version, and compares against the previous and last-good versions.
516-
- [ ] Users can plug in paired comparators (`evaluation.comparators`), N-way cohort aggregators (`evaluation.aggregators`) surfaced by `dow aggregate` as durable git-tracked bundles, and plot functions (`evaluation.plots`) whose figures dow stores as content-addressed artifacts — dow shipping none of the coefficients or plotting code.
533+
- [ ] Users can plug in paired comparators (`evaluation.comparators`), N-way cohort aggregators (`evaluation.aggregators`) surfaced by `dow aggregate` as durable git-tracked bundles, cross-spec suites (`specs/<name>.suite.yaml`) surfaced by `dow suite`, and plot functions (`evaluation.plots`) whose figures dow stores as content-addressed artifacts — dow shipping none of the coefficients or plotting code.
517534
- [ ] `dow tag` applies free-form labels (good, golden, baseline, ...) that are usable as version references.
518535
- [ ] The tool runs end to end offline through mock or local mode.
519536
- [ ] A rehearsed two-minute demonstration that lands the closing statement.

PYPI_README.md

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,31 @@ version carrying a `--tag`, or all of them), runs the aggregators, and saves a d
160160
git-tracked bundle under `.dow/aggregations/`; `dow aggregate --list` and `--show <id>`
161161
retrieve past results.
162162

163+
### Cross-spec suites (the matrix)
164+
165+
`dow aggregate` works within a single spec. A robustness sweep often spans a whole
166+
matrix - the same check across several models, domains, or temperatures, where each cell
167+
is its own spec. `dow suite` aggregates versions drawn from several specs at once. You
168+
declare the participating specs and the project's own aggregators/plots in a manifest,
169+
`specs/<name>.suite.yaml`:
170+
171+
```yaml
172+
# specs/robustness_matrix.suite.yaml
173+
name: robustness_matrix
174+
specs: [check_llama, check_qwen, check_mistral]
175+
select: all # all | latest | <tag>
176+
evaluation:
177+
aggregators: [suite_metrics.py:agg_matrix] # your callables; dow ships none
178+
plots: [suite_plots.py:plot_matrix]
179+
```
180+
181+
Each member keeps its own captured `config` (and the `CohortContext` carries a parallel
182+
`specs` list), so your aggregator can bucket by spec / model / domain / temperature.
183+
Member ids are composite `spec:version`. `select` chooses the cohort: `all` (the full
184+
matrix), `latest` (each spec's newest version), or a tag name. Runs persist as durable,
185+
git-tracked suite bundles, separate from single-spec aggregations, so they never leak
186+
into `dow history`; `dow suite --list`, `--show <id>`, and `--plot` work as for aggregate.
187+
163188
### Pluggable plots
164189

165190
dow can render results to figures without shipping a plotting library: reference your
@@ -172,10 +197,10 @@ evaluation:
172197
- plots.py:forest_plot # your matplotlib (or any) code; dow ships none
173198
```
174199

175-
Run `dow compare --plot` or `dow aggregate --plot`. dow copies each figure into the
176-
content-addressed artifact store (`.dow/artifacts/`, git-ignored) and records its hash
177-
and size; for an aggregation the figure is referenced from the persisted bundle, so it
178-
stays regenerable while the bytes stay out of git.
200+
Run `dow compare --plot`, `dow aggregate --plot`, or `dow suite --plot`. dow copies each
201+
figure into the content-addressed artifact store (`.dow/artifacts/`, git-ignored) and
202+
records its hash and size; for an aggregation or suite the figure is referenced from the
203+
persisted bundle, so it stays regenerable while the bytes stay out of git.
179204

180205
### Non-text outputs
181206

@@ -224,10 +249,10 @@ should operate on:
224249
}
225250
```
226251

227-
The 14 tools mirror the CLI: `dow_list_specs`, `dow_init`, `dow_read_spec`,
252+
The 15 tools mirror the CLI: `dow_list_specs`, `dow_init`, `dow_read_spec`,
228253
`dow_write_spec`, `dow_commit`, `dow_compare`, `dow_explain`, `dow_eval`,
229-
`dow_aggregate`, `dow_history`, `dow_inspect`, `dow_tag`, `dow_tree`, and
230-
`dow_docs`. They return structured JSON (config diffs, metrics, comparator and
254+
`dow_aggregate`, `dow_suite`, `dow_history`, `dow_inspect`, `dow_tag`, `dow_tree`,
255+
and `dow_docs`. They return structured JSON (config diffs, metrics, comparator and
231256
aggregator results, the version tree, and Mermaid - plus, for text outputs, drift
232257
scores and verdicts), so a client can run the full edit, commit, compare loop.
233258
The server also exposes read-only resources an MCP client can attach as context:

README.md

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,33 @@ version carrying a `--tag`, or all of them), runs the aggregators, and saves a d
183183
git-tracked bundle under `.dow/aggregations/`; `dow aggregate --list` and `--show <id>`
184184
retrieve past results.
185185

186+
### Cross-spec suites (the matrix)
187+
188+
`dow aggregate` works within a **single** spec. A robustness sweep, though, often spans
189+
a whole **matrix** - the same check across several models, domains, or temperatures,
190+
where each cell is its own spec. `dow suite` aggregates versions drawn from **several**
191+
specs at once. You declare the participating specs and the project's own aggregators/
192+
plots in a manifest, `specs/<name>.suite.yaml`:
193+
194+
```yaml
195+
# specs/robustness_matrix.suite.yaml
196+
name: robustness_matrix
197+
specs: [check_llama, check_qwen, check_mistral] # the specs to draw versions from
198+
select: all # all | latest | <tag>
199+
evaluation:
200+
aggregators: [suite_metrics.py:agg_matrix] # your callables; dow ships none
201+
plots: [suite_plots.py:plot_matrix]
202+
```
203+
204+
Each member keeps its own captured `config`, so your aggregator can bucket by spec /
205+
model / domain / temperature (the `CohortContext` also carries a parallel `specs` list
206+
naming each member's spec). Member ids are composite `spec:version`. `select` chooses
207+
the cohort: `all` (every version of each listed spec - the full matrix), `latest` (each
208+
spec's newest version), or a tag name (each spec's versions carrying that tag). Runs are
209+
saved as durable, git-tracked suite bundles (kept separate from single-spec
210+
aggregations, so they never leak into `dow history`); `dow suite --list` and `--show
211+
<id>` retrieve them, and `--plot` renders the manifest's plot functions.
212+
186213
### Pluggable plots
187214

188215
dow can render results to figures without shipping a plotting library: reference your
@@ -195,10 +222,10 @@ evaluation:
195222
- plots.py:forest_plot # your matplotlib (or any) code; dow ships none
196223
```
197224

198-
Run `dow compare --plot` or `dow aggregate --plot`. dow copies each figure into the
199-
content-addressed artifact store (`.dow/artifacts/`, git-ignored) and records its hash
200-
and size; for an aggregation the figure is referenced from the persisted bundle, so it
201-
stays regenerable while the bytes stay out of git.
225+
Run `dow compare --plot`, `dow aggregate --plot`, or `dow suite --plot`. dow copies each
226+
figure into the content-addressed artifact store (`.dow/artifacts/`, git-ignored) and
227+
records its hash and size; for an aggregation or suite the figure is referenced from the
228+
persisted bundle, so it stays regenerable while the bytes stay out of git.
202229

203230
### Non-text outputs
204231

@@ -250,10 +277,10 @@ should operate on:
250277

251278
Each tool resolves its project directory from the `project_dir` argument, else
252279
the `DOW_PROJECT_DIR` environment variable, else the server's current directory.
253-
The 14 tools mirror the CLI: `dow_list_specs`, `dow_init`, `dow_read_spec`,
280+
The 15 tools mirror the CLI: `dow_list_specs`, `dow_init`, `dow_read_spec`,
254281
`dow_write_spec`, `dow_commit`, `dow_compare`, `dow_explain`, `dow_eval`,
255-
`dow_aggregate`, `dow_history`, `dow_inspect`, `dow_tag`, `dow_tree`, and
256-
`dow_docs`. They return structured JSON (config diffs, metrics, comparator and
282+
`dow_aggregate`, `dow_suite`, `dow_history`, `dow_inspect`, `dow_tag`, `dow_tree`,
283+
and `dow_docs`. They return structured JSON (config diffs, metrics, comparator and
257284
aggregator results, the version tree, and Mermaid - plus, for text outputs, drift
258285
scores and verdicts), so a client can run the full edit -> commit -> compare loop.
259286

dow/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.4"
1+
__version__ = "2.1.0"

dow/cli.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ def _spec_path(name: str) -> Path:
4444

4545
def _spec_files() -> list:
4646
d = _specs_dir()
47-
return sorted(d.glob("*.yaml")) if d.is_dir() else []
47+
if not d.is_dir():
48+
return []
49+
return sorted(f for f in d.glob("*.yaml") if not f.name.endswith(service.SUITE_SUFFIX))
4850

4951

5052
def _find_spec_name(name: Optional[str]) -> Optional[str]:
@@ -232,6 +234,39 @@ def aggregate(
232234
report.print_aggregation(result)
233235

234236

237+
@app.command(**_doc("suite"))
238+
def suite(
239+
name: Optional[str] = typer.Argument(
240+
None, help="Suite manifest name (specs/<name>.suite.yaml)."),
241+
select: Optional[str] = typer.Option(
242+
None, "--select", help="Override selection: all | latest | <tag>."),
243+
plot: bool = typer.Option(False, "--plot", help="Also run the suite's plot functions and store the figures."),
244+
list_: bool = typer.Option(False, "--list", help="List stored suite bundles instead of running one."),
245+
show: Optional[str] = typer.Option(None, "--show", help="Print a stored suite bundle by id (e.g. a1)."),
246+
) -> None:
247+
"""Aggregate versions across several specs (the check x model x domain x temperature matrix)."""
248+
try:
249+
sname = service.need_suite(_root(), name)
250+
except service.DowError as exc:
251+
raise typer.BadParameter(str(exc))
252+
if list_:
253+
data = service.suite_aggregations(_root(), sname)
254+
report.print_aggregation_list(sname, data["aggregations"])
255+
return
256+
if show:
257+
try:
258+
bundle = service.get_suite_aggregation(_root(), sname, show)
259+
except service.DowError as exc:
260+
raise typer.BadParameter(str(exc))
261+
report.print_aggregation(bundle)
262+
return
263+
try:
264+
result = service.aggregate_suite(_root(), name=sname, select=select, plot=plot)
265+
except service.DowError as exc:
266+
raise typer.BadParameter(str(exc))
267+
report.print_aggregation(result)
268+
269+
235270
@app.command(**_doc("explain"))
236271
def explain(
237272
a: Optional[str] = typer.Argument(None, help="First version (default: previous)."),

0 commit comments

Comments
 (0)