Files
Aura/docs/specs/sweep-dissolution.md
T
Brummel 3fd1cf9593 docs(cycle): sweep-dissolution spec + plan (cycle-live working artifacts)
Cycle 1 of the verb-dissolution milestone (refs #210): the real-data
blueprint sweep re-cut as sugar over a generated, auto-registered
campaign document; optional selection group in the sweep stage
vocabulary; terminal-only preflight rule. Spec auto-signed on a
grounding-check PASS (decision log on #210); ephemeral per convention,
removed at cycle close.
2026-07-04 16:59:44 +02:00

14 KiB

Sweep dissolution — the real-data blueprint sweep as campaign sugar — Design Spec

Date: 2026-07-04 Status: Draft — awaiting sign-off Authors: orchestrator + Claude

Cycle 1 of the milestone "Verb dissolution — the campaign path as canonical orchestration" (anchor issue #210; design basis: the #210 coverage map + the fork-triage comment of 2026-07-04, all forks decided). This cycle re-cuts the real-data blueprint branch of aura sweep as thin sugar over a generated, auto-registered campaign document executed by the one campaign executor. The built-in and synthetic branches stay verb-wired (#210 decision 2); the other three verbs follow in later cycles riding on this cycle's translator skeleton (#210 decision 7).

Goal

aura sweep <blueprint.json> --real SYM --from A --to B --axis k=v,… [--name N] stops executing through its inline run_blueprint_sweep path and instead:

  1. registers the blueprint (already shipped behaviour, kept),
  2. generates a selection-free single-stage process document and a campaign document expressing exactly the invocation's intent,
  3. auto-registers both into the content-addressed stores (#210 decision 6),
  4. runs them through aura-campaign's executor via the existing CliMemberRunner,
  5. prints the same member lines as today's verb output, modulo one additive field: the campaign substrate stamps "instrument":"<SYM>" into each member's manifest (campaign_run.rs stamps manifest.instrument; the inline path leaves it None and serde omits it). The stamp is kept — it is provenance the substrate deliberately records; stripping it in sugar mode would fork the substrate's behaviour for presentation cosmetics. No existing test pins the field's absence on the dissolved branch (#210 decision 5, refined by the grounding-check finding of 2026-07-04).

The invocation's intent — instrument, window, axes, strategy — thereby becomes durable, diffable, reproducible data instead of evaporating into shell history (#188 diagnosis). The inline real-data execution arm is deleted in the same cycle; "thin sugar" replaces the path, it never adds a parallel one.

Prerequisite vocabulary change (#210 decision 1): the std::sweep stage's selection becomes optional — permitted only when the sweep is the terminal stage of its pipeline.

Architecture

Three layers change; the seams between them are all shipped:

  • aura-research — the std::sweep block's selection triple (metric, select, deflate) becomes an optional group: either all absent (a selection-free sweep) or metric+select present (deflate defaulting as today). A half-populated triple is unrepresentable — the hand-rolled schema-strict deserializer refuses metric without select and vice versa. Introspection (schema tables, --node describe) and the canonical form follow: the absent group serializes as absent fields, so every existing document's canonical bytes and content id are unchanged.
  • aura-campaign — preflight gains one structural rule: a selection-free sweep is permitted iff it is the pipeline's terminal stage (every downstream stage consumes the family's selection state or the nominee; refusing non-terminal selection-free sweeps keeps that contract intact). The executor skips select_sweep_winner for a selection-free sweep: no StageSelection is recorded (recording one would fabricate intent — the audit-lie all triage lenses rejected), no nominee is produced, the family is appended exactly as today.
  • aura-cli — a new translator module (the shared skeleton later verb cycles extend) maps the verb's argv to the two generated documents, auto-registers them, invokes the campaign run path, and presents the result through the verb's own stdout contract (member lines only; the campaign path's selection_report and final campaign_run stdout lines are suppressed in sugar mode — the run record itself is still appended to campaign_runs.jsonl). The --real arm of run_blueprint_sweep's dispatch is replaced by this path; the synthetic arm and --list-axes stay untouched.

Concrete code shapes

The user-facing invocation (unchanged surface, new substrate)

$ aura sweep blueprints/signal.json --real GER40 \
    --from 1725148800000 --to 1727740800000 \
    --axis sma_fast.LENGTH=8,12,16 --name probe
{"family_id":"…","report":{…}}
{"family_id":"…","report":{…}}
{"family_id":"…","report":{…}}

Stdout: one member line per grid point, the same family_member_line shape as today plus the additive "instrument":"GER40" manifest stamp (see Goal, point 5). New observable effects, all additive:

$ ls runs/processes runs/campaigns    # both docs now exist, content-addressed
runs/processes/<process-id>.json
runs/campaigns/<campaign-id>.json
$ aura campaign runs                  # the realized run is recorded
{"campaign_run":{"campaign":"<campaign-id>","name":"probe",…}}

The generated documents (exact intent, closed vocabulary)

Process document (constant for every selection-free sweep — repeated ad-hoc sweeps dedupe onto one stored doc by content id):

{
  "format_version": 1,
  "kind": "process",
  "name": "sweep",
  "pipeline": [ { "block": "std::sweep" } ]
}

Campaign document (one per distinct invocation shape; --name becomes the campaign name, defaulting as the verb defaults today):

{
  "format_version": 1,
  "kind": "campaign",
  "name": "probe",
  "data": { "instruments": ["GER40"],
            "windows": [ { "from_ms": 1725148800000, "to_ms": 1727740800000 } ] },
  "strategies": [ { "ref": { "content_id": "<blueprint-content-id>" },
                    "axes": { "sma_fast.LENGTH": { "kind": "I64", "values": [8, 12, 16] } } } ],
  "process": { "ref": { "content_id": "<process-id>" } },
  "seed": 0,
  "presentation": { "persist_taps": [], "emit": ["family_table"] }
}

seed: 0: no stage of a selection-free single-sweep pipeline consumes the seed (no deflation, no bootstrap); a fixed zero keeps the generated bytes deterministic so identical invocations produce identical content ids.

Vocabulary change, before → after (aura-research)

// before
StageBlock::Sweep { metric: String, select: SelectRule, deflate: bool }

// after — the selection triple is one optional group
StageBlock::Sweep { selection: Option<SweepSelection> }
pub struct SweepSelection { pub metric: String, pub select: SelectRule, pub deflate: bool }

Wire form is unchanged for existing documents: {"block":"std::sweep", "metric":"…","select":"…"} parses into Some(SweepSelection{…}); {"block":"std::sweep"} parses into None; metric without select (or vice versa) is a schema error naming the incomplete group. Serialization omits the absent group, so canonical bytes of every existing document are byte-identical and no stored content id moves.

Preflight rule, before → after (aura-campaign)

// before: sweep always selects; nothing to check.
// after: a selection-free sweep must be terminal.
if sweep.selection.is_none() && !is_terminal_stage {
    return Err(ExecFault::SelectionFreeSweepNotTerminal {  });
}

There is no separate preflight fault type — preflight returns ExecFault (aura-campaign/src/lib.rs), whose prose lives in aura-cli's exhaustive exec_fault_prose match; the new variant extends both (compiler-enforced).

Executor arm: selection: None → append the family, emit family_table lines per the presentation, record the stage realization without a StageSelection, produce no nominee.

Verb dispatch, before → after (aura-cli)

// before (dispatch_sweep, blueprint branch, --real present):
run_blueprint_sweep(doc, axes, name, persist, DataSource::Real{})   // inline exec

// after:
let gen = translate_sweep(&argv)?;          // argv → (ProcessDoc, CampaignDoc)
let ids = register_generated(&reg, &gen)?;  // auto-register both, content-addressed
run_generated_campaign(&reg, env, ids, RunPresentation::MemberLinesOnly)?;
// The presentation mode is a parameter threaded through the existing
// campaign-run entry (`campaign_run.rs`): `aura campaign run` keeps the
// full mode (member lines + selection_report + final campaign_run line);
// sugar mode suppresses everything but the family_table member lines.
// The record append is untouched in both modes.

The synthetic arm (--real absent) keeps calling the existing inline path unchanged; --list-axes keeps its probe path unchanged.

Components

  1. aura-research: SweepSelection extraction; hand-rolled deserializer update (group-or-nothing rule + refusal prose); canonical-form serialization keeps omit-absent; introspection contract updated (the sweep block's schema table marks the selection group optional-terminal); intrinsic validation unchanged otherwise.
  2. aura-campaign: preflight terminal-only rule + fault variant with house-style prose; executor selection-free arm (no StageSelection, no nominee); PER_MEMBER_METRICS/RANKABLE_METRICS untouched.
  3. aura-cli: verb_sugar translator module — translate_sweep (argv → documents; CSV axis parsing reuses the shipped --axis parser and the axis-kind derivation from the blueprint's open params), generated-doc registration, sugar-mode presentation (suppress selection_report + campaign_run stdout lines; member lines pass through), dispatch rewire of the --real blueprint arm, deletion of the now-dead inline real-data execution arm of run_blueprint_sweep.

Data flow

argv ──translate_sweep──▶ ProcessDoc + CampaignDoc (canonical JSON)
     ──register_generated──▶ runs/processes/<id>.json, runs/campaigns/<id>.json
     ──campaign run path──▶ preflight ▶ executor ▶ CliMemberRunner (real archive)
                             ├─▶ families.jsonl   (FamilyKind::Sweep, as today)
                             ├─▶ campaign_runs.jsonl (the realized-intent record)
                             └─▶ stdout: family_table member lines ONLY (sugar mode)

Family naming follows the campaign convention ({campaign8}-…-w0-s0); the user's --name handle lives on the campaign document and its run record. The member stdout lines never carried the family name; their only delta against the inline path is the additive instrument manifest stamp (Goal, point 5).

Error handling

  • Verb-surface refusals keep the shipped exit-code contract: usage errors exit 2 (clap, unchanged), runtime refusals exit 1 with aura: … prose.
  • Translator refusals (unknown axis name, malformed CSV, missing geometry) reuse the existing refusal prose paths — the pinned rejection tests keep their bytes.
  • Preflight faults surface through the existing campaign fault prose; the new SelectionFreeSweepNotTerminal prose names the rule and the fix ("a sweep without a selection must be the last stage").
  • A generated document that fails validation is a bug, not a user error — the translator's output is preflighted before any member runs, so a fault aborts with exit 1 before touching the archive.

Testing strategy

  1. Characterization first (the reproduction evidence) — SHIPPED. The pin test sweep_real_blueprint_member_lines_pin_the_inline_contract (crates/aura-cli/tests/cli_run.rs, gated, green on this host) pins the inline path's observable behaviour: member count (exactly 4 stdout lines) and odometer order, the swept bindings in manifest.params, exactly one FamilyKind::Sweep family with 4 members — and, as of today, the absence of the instrument manifest key on the inline path. The re-cut task flips exactly that one assertion (absence → equals the --real symbol, the sanctioned additive delta); every other assertion survives unchanged, including the exactly-4-lines pin, which forces sugar mode to suppress the campaign path's final campaign_run stdout line. The sibling pin (the campaign runner's stamp) lives in campaign_run_real_e2e_sweep_gate_walkforward and is untouched.
  2. Translator unit tests (ungated, pure). argv → exact canonical JSON bytes of both generated documents (including seed: 0, axis kinds, the content_id refs); identical invocations → identical content ids; --name flows to the campaign name.
  3. Vocabulary tests (ungated, aura-research). Selection-free sweep parses/serializes/round-trips; half-populated group refused with named prose; existing fixture documents' canonical bytes and content ids unchanged (the no-drift pin).
  4. Preflight tests (aura-campaign). Selection-free terminal sweep accepted; selection-free sweep followed by any stage refused; executor records no StageSelection and produces no nominee for the selection-free arm.
  5. Existing suite green unchanged. All current sweep tests (synthetic, built-in, --list-axes, rejection prose) stay untouched and green — they pin the branches this cycle does not dissolve.

Acceptance criteria

  • The --real blueprint form of aura sweep executes through the campaign executor; its inline real-data execution arm is deleted (no parallel path).
  • Both generated documents are auto-registered content-addressed on every sugar run; a campaign_run record is appended; repeated identical invocations dedupe onto the same document ids.
  • Member-line stdout of the dissolved form is identical to the pre-change verb modulo the additive instrument manifest stamp: the shipped characterization pin survives the re-cut with exactly one sanctioned assertion flip (instrument-absence → instrument-equals-symbol); all its other assertions — including exactly 4 stdout lines — are untouched.
  • A selection-free std::sweep is a valid terminal stage of the document vocabulary — parseable, introspectable, content-addressable — and refused with named prose anywhere else in a pipeline; no existing document's content id moves.
  • Synthetic/built-in sweep branches, --list-axes, and all their pinned tests are untouched.
  • cargo test --workspace green; cargo clippy --workspace --all-targets -- -D warnings clean.