One output is one file, named by the spec - #199
Conversation
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
✅ Eval
lc statusConfusion & pain points (Claude analysis)Confusion & pain points
Full trace: |
|
Claude Code review — 1 finding(s) nit — evals/prompt.md:33-34 The manifest sidecar was renamed to 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
`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
|
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
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
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.npzandrun_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 showsdata-canonical-url=…corner.pdfnext tosrc=…corner.png).ASTRA now declares the missing half —
Output.format, astra-spec 0.0.14 / astra-tools 0.2.17 — the counterpart ofInput.sourcehaving always carried a path with an extension.The layout
formatcomes from the spec, so lc composes the filename rather than a recipe choosing it — which makes the whole contents ofresults/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>, andoutput_pathrefuses 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. Onmainsuch an output landed in a flat directory named by its dotted id. #202 records what supporting it properly would take.What this buys
savefig(args.out)writes exactly one file at exactly the declared path.{inputs.X}renders to the upstream's file.astra.yamlalone.data_versiondrops itsfile:frame, so the manifest, the crate'ssha256andsha256sumagree: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_versionremains 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 withoutformaton 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.gzyielding.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:lc statusandlc materialize --check#200 — a malformedastra.yamltracebacks out oflc statusandlc materialize --checkas a rawScannerError, contradicting the invariant it now sits under.source:resolves against the project root, not its own analysis root #201 — a declaredsource:resolves against the project root wherever it is declared.validate_universe_filevalidates against the unresolved tree, so a nested external sub-analysis fails with[UNKNOWN_ANALYSIS]at depth 2+).🤖 Generated with Claude Code
https://claude.ai/code/session_01PybSgBiP8pjZeWxxCxiAXi