Skip to content

One output is one file, named by the spec - #199

Merged
EiffL merged 7 commits into
mainfrom
one-output-one-file
Aug 26, 2026
Merged

One output is one file, named by the spec#199
EiffL merged 7 commits into
mainfrom
one-output-one-file

Conversation

@EiffL

@EiffL EiffL commented Aug 25, 2026

Copy link
Copy Markdown
Member

An output was a directory a recipe filled, so nothing declared what was in it. In a real agent-run project that meant one output holding chain.npz and run_config.json, with the filename contract living only inside three consumer scripts; a recipe exiting 0 having written nothing committing a well-formed digest of an empty directory; and MySTRA guessing which file in a figure's directory was the figure (its own output shows data-canonical-url=…corner.pdf next to src=…corner.png).

ASTRA now declares the missing half — Output.format, astra-spec 0.0.14 / astra-tools 0.2.17 — the counterpart of Input.source having always carried a path with an extension.

The layout

results/<universe>/<output_id>.<format>          the output
results/<universe>/.<output_id>.manifest.json    its manifest, in git

format comes from the spec, so lc composes the filename rather than a recipe choosing it — which makes the whole contents of results/ a pure function of the spec.

Scope: a flat analysis, deliberately

This PR handles an analysis without sub-analyses, and nothing else. An earlier revision carried a results tree per sub-analysis, with the guards that needed — a path-collision refusal, an escape refusal, a missing-universe-file refusal, a broadened **/results/** routing rule, a path-shaped dirty-tree split, and a multi-root crate walk. All of it was removed: none is required by the change this PR is for.

The consequence, stated plainly because it is a real capability removed: a nested spec is no longer buildable. ASTRA qualifies an output declared in a sub-analysis as <analysis>.<output>, and output_path refuses an id carrying a dot — the sidecar is recovered by partitioning the filename on its first dot, so a dotted id would name a manifest two outputs could silently share. On main such an output landed in a flat directory named by its dotted id. #202 records what supporting it properly would take.

What this buys

  • The multiplicity has nowhere to come from. No directory to compose a second name into, so savefig(args.out) writes exactly one file at exactly the declared path.
  • The producer/consumer filename contract is gone{inputs.X} renders to the upstream's file.
  • A consumer that never runs anything knows what every output is, from astra.yaml alone.
  • One digest, not two. data_version drops its file: frame, so the manifest, the crate's sha256 and sha256sum agree:
    manifest data_version: sha256:4f2680c28efa…
    sha256sum            : sha256:4f2680c28efa…
    

Refusals over guesses

All before anything is spent: an output with no format (named, all of them at once); an id or format that cannot compose a path; a payload that is not a regular file (mkdir {output} used to commit a directory digest); and a manifest the attributes would route into the annex.

One reversal, recorded

The sandbox scope widens from the per-output directory to the directory an output lands in. Not a preference — a mechanism can only grant creating a file through the directory that will hold it, and the Landlock shim masks a non-directory grant to _FILE_ONLY_BITS. Outputs declared side by side are mutually writable again; data_version remains what answers for an output's bytes.

Breaking, deliberately

Every project restages; old results/<u>/<o>/ directories are invisible to the new code (git rm -r results/ and re-materialize — no migration path, per the no-back-compat rule); a spec without format on every executable output is refused; multi-file outputs must become separate outputs or a container format; and nested specs are refused (above).

Verification

614 passed, ruff and mypy clean. Beyond the suite, a real project materialized end to end: payloads annexed and manifests git-carried, tar.gz yielding .chain.manifest.json (first-dot partition, not .stem), tree clean after, and a bytes-free clone fetching its inputs and landing on up-to-date with nothing remade — which only happens if every manifest stayed readable without content.

Noted, not fixed

All pre-existing and independent of this change, recorded in CLAUDE.md:

🤖 Generated with Claude Code

https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi

EiffL and others added 2 commits August 25, 2026 18:50
An output was a directory a recipe filled, so nothing declared what was
in it. A real agent-run project put chain.npz and run_config.json in one
output, with the filename contract living only inside three consumer
scripts; a recipe that exited 0 having written nothing committed a
well-formed digest of an empty directory; and MySTRA had to guess which
file in a figure's directory was the figure.

ASTRA now declares the missing half (Output.format, astra-spec 0.0.14 /
astra-tools 0.2.17), the counterpart of Input.source having always
carried a path with an extension. So an output becomes exactly one file
at a spec-derived path, and results live beside the astra.yaml that
declares them:

    <home>/results/<universe>/<inline scope…>/<local_id>.<format>
    <home>/results/<universe>/<inline scope…>/.<local_id>.manifest.json

An inline sub-analysis shares its parent's home and takes a scope
directory; one declared with `path:` is a self-similar analysis with its
own home and, where the parent universe names one, its own universe id.
Only the path nests — graph keys and CLI targets stay the qualified id.

The multiplicity had nowhere else to come from: there is no directory to
compose a second name into, so `savefig(args.out)` writes exactly one
file at exactly the declared path. Consumers are handed the upstream's
file rather than a directory to search.

Refusals over guesses, all before anything is spent: an output with no
`format`, two tasks composing one path, a sub-analysis home escaping the
project, a `universe:` naming a file that is not there, a payload that
is not a regular file, and a manifest the attributes would route into
the annex.

`format` is framed into definition_version rather than left to arrive
through the rendered {output} path — a recipe need not use that
placeholder (evals/tasks/snae does not), and one that does not would
keep its digest across a re-declared serialization while the sidecar,
named from the id alone, did not move either.

data_version drops the `file:` frame, so an output's digest is the
number sha256sum prints and the one the crate publishes — one number
instead of two. Only directory-valued declared inputs stay framed.

The sandbox scope becomes the directory an output lands in, reversing
the hardening pass's per-output narrowing: a mechanism can only grant
creating a file through the directory that will hold it, and Landlock
masks a non-directory grant to _FILE_ONLY_BITS. Outputs declared side by
side are mutually writable again; data_version remains what answers for
an output's bytes.

`results/**` stays in the template though `**/results/**` subsumes it: a
managed line dropped from the template stops being ranked, which blinds
gitattributes_disorder to the trap it exists for.

No migration. Nothing looks for .lightcone-manifest.json and nothing
detects directory-shaped outputs from a previous engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi
The docs track the layout: `{output}` is a file, a sub-analysis keeps
its results beside its own spec, data_version is a plain sha256, an
output is a crate `File` rather than a Dataset of parts.

The eval seed leaves `format:` out deliberately — completing the spec is
the task — and its prompt no longer tells the agent under test to write
artifact files inside a directory.

CLAUDE.md records the decision and two things that are not obvious from
the code: that the sandbox narrowing was reversed by mechanism rather
than by choice, and that `results/**` is kept in the template only to
keep the ordering guard from going blind.

Also written down, pre-existing and unfixed: a malformed astra.yaml
tracebacks out of `lc status` and `lc materialize --check` as a raw
ScannerError, which contradicts the invariant it now sits under; and a
sub-analysis's declared `source:` still resolves against the project
root rather than its own home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

✅ Eval

Metric Value
Outputs check success
Agent run success
Turns 35
Tool calls 33
Cost $0.72
Agent wall time 4m13s
Model claude-sonnet-5
lc status
  mode:    direct
  sandbox: landlock (fs: declared, network: allowed)
  crate:   up to date with the outputs

  · current  baseline/best_fit        4397a86
  · current  baseline/hubble_diagram  4397a86
  · current  baseline/residuals       4397a86

3 current
Confusion & pain points (Claude analysis)

Confusion & pain points

  • RO-Crate publication view required a second, unguided round-trip. The agent's spec had no [project].license, so the first lc materialize silently skipped RO-Crate generation with only a warning line (! no [project].license in pyproject.toml…). The agent had to notice the warning, invent a license value (CC-BY-4.0) unprompted by any spec/task requirement, edit pyproject.toml, commit, and re-materialize just to get ro-crate-metadata.json produced. This is a case where publication intent is derived rather than declared by the task, forcing the agent to make an editorial decision (which license) that wasn't part of the actual analysis.
  • Reverse-engineered the data file format from scratch. Rather than any documented schema, the agent ran multiple ad hoc awk/grep probes on data/SCPUnion2.1_mu_vs_z.txt (column counts, min/max redshift, error-column semantics) to figure out which of the 5 columns were which and how error_model decisions (stat_only vs stat_and_sys) should combine them. This is inherent to the task (no data dictionary shipped), but it cost several exploratory Bash calls before any code was written.
  • Manually validated the stat/error-column mapping via an out-of-band lc run. After materializing the baseline, the agent used lc run on fit.py with --error-model stat_only to spot-check that H0≈70/reduced χ²≈0.97 matched textbook expectations — effectively a manual sanity test the harness/spec gave no built-in way to assert. This is reasonable diligence but highlights there's no lightweight "does this recipe make physical sense" check short of eyeballing numbers.
  • Otherwise a clean run. No failed commands, no sandbox denials, no misreadings of the CLI surface — lc materialize, lc status, and astra validate all worked as expected on the first try after the spec was filled in.

Full trace: agent-trace artifact on this run.

EiffL commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Claude Code review — 1 finding(s)

nit — evals/prompt.md:33-34

The manifest sidecar was renamed to .<output_id>.manifest.json, but two doc references to the old .lightcone-manifest.json name were missed: evals/prompt.md:34 (the very next line was updated to the new results/baseline/<output_id>.<format> layout) and CLAUDE.md:481 in the storage invariants (whose own Recorded decision, added by this PR, documents the new pattern). Both are one-line doc drift with no runtime impact — but CLAUDE.md's own rule is "document only what exists," and the eval prompt is fixture text every future eval agent reads.


Generated by Claude Code

The sidecar rename left `.lightcone-manifest.json` in two places the
rest of the change updated: the eval prompt, whose preceding line had
already moved to the new file-shaped layout, and the layer-1 storage
invariant, which named a `.gitattributes` pattern the template no longer
carries — contradicting this branch's own Recorded decision.

The third mention stays: the no-migration decision names that file
precisely because nothing looks for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi
EiffL and others added 2 commits August 25, 2026 20:05
`lc init` writes the version the installed astra-spec provides, and the
eval harness then overlays a seed whose version is pinned by hand — so
bumping astra-tools to 0.2.17 (astra-spec 0.0.14) left every `astra
validate` call in the eval warning about a 0.0.13 declaration, training
the agent under test to read past version warnings.

The seed is bumped, and the harness now puts the scaffold's answer back
after the overlay so the same drift cannot return on the next release.
The remaining warning in the eval is the intentional one: the seed
declares no `format:` because supplying it is part of the task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi
Every analysis node has a directory holding its own `astra.yaml`, which
is what its results hang off. "Home" was a coinage; "analysis root" says
the same thing in the vocabulary the rest of the codebase already uses.

Not bare `root`: that already means the *project* root in every
signature this sits beside, and `{self.root, *(task.root for …)}` in the
crate would read as two different roots in one expression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi
@cailmdaley

Copy link
Copy Markdown
Member

this is the most Opus 5 PR i've ever seen haha. i got clickbaited by the PR name

The one-file layout arrived carrying support for sub-analyses: an
analysis-root walk, scope directories, a results tree per external
sub-analysis, and the guards each of those needed — a path-collision
refusal, a missing-universe-file refusal, an escape refusal, a broadened
`**/results/**` routing rule with its own collateral, a path-shaped
dirty-tree split, and a multi-root crate walk.

None of that is required by the change this PR is for. An output is
`results/<universe>/<output_id>.<format>`, one results tree, and the
whole of it composed from the spec.

`output_path` refuses an output id carrying a dot: the sidecar is
recovered by partitioning the filename on the first one, so a dotted id
would name a manifest that could not be told from the payload. ASTRA's
id grammar carries no dot, so this only fires on an id lc did not
compose — which is what makes a nested spec unbuildable today, recorded
as #202 with what deciding it would take.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi

@EiffL EiffL left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments

Comment thread .github/workflows/eval.yml
Comment thread docs/api/assets.md Outdated
Comment thread src/lightcone/engine/materialize.py Outdated
Comment thread src/lightcone/engine/materialize.py Outdated
Comment thread src/lightcone/engine/materialize.py Outdated
Review comments, in order:

- The `check-attr` preflight and `dataset.largefiles` are gone.
  `.gitattributes` is convergence's file: its repair appends the
  exemption and its disorder guard blocks what it cannot fix, so a
  second answer in the run path is one more place for the two to
  disagree. The residue is recorded.
- The eval harness no longer rewrites the seed's spec version. The seed
  is bumped and that is the whole of it.
- `docs/api/assets.md` documented `output_path(analysis_root, u, scope,
  id, fmt)`; the signature has been `(root, universe_id, output_id,
  fmt)` since the flat layout landed.

And what running the tutorial turned up — the docs rule is to verify
every block by executing it, which is exactly what had not been done:

- `src/fit.py` and `src/plot.py` still wrote into `{output}` as a
  directory, so the whole walkthrough raised `NotADirectoryError` on
  step one. Both now write the file they are given.
- The `lc status` sample was missing the `crate:` line a real run
  prints. Verified by building the tutorial from the doc's own blocks:
  it produces the fit and a 960x720 PNG, and is idempotent.

Two fixes the review found that were mine:

- `restore` guarded its checkout with `ls-files --with-tree=HEAD`, which
  also lists what is staged but unborn — exactly where `checkout HEAD --`
  then exits nonzero. An interrupted save would make the `finally`
  restore raise over whatever interrupted it. The checkout goes through
  `_git_ok` instead, with no guard: a pathspec that matches nothing is a
  no-op.
- `tracked_manifests` globbed the whole repository, so any tracked file
  named `*.manifest.json` reached `assets.read`, which raised
  `AttributeError` on a top-level array and `UnicodeDecodeError` on
  bytes. Scoped to `results/`, and `read` now answers `None` for
  anything that is not a manifest object.

Plus stale prose the simplification left: the `plan` and `assets` module
docstrings still described analysis roots and scope directories, the
orphan walk's comment justified itself with a case that no longer
exists, `docs/api/worker.md` named the removed `local_id`, `CLAUDE.md`
carried two spellings of the manifest attribute line, and the crate's
`encodingFormat` line could never differ from what `_file()` already set
(`_FORMATS` has no multi-dot key — checked).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi
@EiffL
EiffL merged commit 78059fa into main Aug 26, 2026
8 of 9 checks passed
@EiffL
EiffL deleted the one-output-one-file branch August 26, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants