The primary architecture reference for C-Star Forge, describing the current
state of the code. Historical notes — how the repo arrived here, resolved follow-ups, planning
documents — live in the repo’s git history (the former docs/dev-notes/
directory); the distilled design rationale agents need is in the claude-docs
repo (cstar-forge/DESIGN-RATIONALE.md).
1. The big picture¶
Forge is split into two layers along a hard boundary, in preparation for moving the execution half into C-Star as an “application”:
Authoring (stays in this repo): the catalog of reusable specs (Model/Domain/ Forcing/Output specs), a resolver that assembles them into a single reviewable file, and a wizard UI.
Execution (
cstar_forge/forge/, target: relocates into C-Star wholesale): an engine that turns that file into ROMS-MARBL input NetCDFs, a namelist, and a downstream blueprint, plus the executor that does the actual work. Execution never touches the catalog.
The file that crosses the boundary is ForgeBlueprint — the forge application’s own
blueprint. Terminology trap to avoid: C-Star also has an existing, unrelated
roms_marbl application whose blueprint (RomsMarblBlueprint) forge emits as an
output artifact. “Building a blueprint” means producing that downstream artifact,
not forge’s own input.
catalog specs ─┐
(Model/Domain/ ├─► build_forge_blueprint() ─► ForgeBlueprint ─► process_forge_blueprint(cfg, host)
Forcing/Output)│ (resolver) (.yaml, (engine → executor)
│ portable) │
wizard UI ──────┘ ▼
input NetCDFs, namelist.nml,
cppdefs.opt, roms_marbl blueprint2. Directory map¶
cstar-forge/
├── cstar_forge/ # Main package directory
│ ├── forge_blueprint_resolve.py # resolver: build_forge_blueprint(...)
│ ├── forge_blueprint_wizard.py # ForgeBlueprintWizard (ipywidgets UI) +
│ │ # ForgeBlueprintWizardApp (adds catalog-location bar)
│ ├── forge-blueprint-wizard.ipynb # user-facing wizard notebook (run in Jupyter)
│ ├── models.py # Spec classes (ModelSpec, etc.)
│ ├── domain_catalog.py # DomainCatalog: scans the catalog, exposes accessors;
│ │ # LayeredCatalog stacks a writable user layer
│ │ # (default_catalog_stack(): ~/cstar-forge-data/catalog,
│ │ # or CSTAR_FORGE_CATALOG) over the read-only bundled
│ │ # catalog — this stack is the module's default_catalog
│ ├── config.py # Path management and system detection
│ ├── run.py # CLI entry point: python -m cstar_forge.run forge_blueprint.yaml
│ ├── cli.py # 'cstar forge run'/'wizard'/'register-kernel' typer sub-app (cstar.cli entry point)
│ ├── register_kernel.py # Jupyter kernelspec + activation wrapper (backs 'cstar forge register-kernel')
│ ├── ui/ # Wizard presentation layer (Voilà app front-end)
│ │ ├── _voila_app.ipynb # Voilà app notebook — internal; served via
│ │ │ # run-wizard-app.sh / 'cstar forge wizard'
│ │ ├── branding.py # [C]Worthy header bar, favicon, page title
│ │ └── assets/cworthy-logo.png # bundled logo (embedded as a data URI)
│ ├── forge/ # The forge application (execution engine —
│ │ │ # relocates into C-Star as one unit)
│ │ ├── app.py # ForgeRunner/ForgeApplication (C-Star application)
│ │ ├── forge_blueprint.py # ForgeBlueprint — the forge application's blueprint
│ │ ├── forge_blueprint_engine.py # process_forge_blueprint(); ForgeBlueprintExecutor Protocol
│ │ ├── executor.py # ForgeExecutor — the processing engine
│ │ ├── host.py # HostPaths — frozen host-boundary contract injected into the executor
│ │ ├── input_data.py # Input file generation
│ │ ├── source_data.py # Dataset download and preparation
│ │ ├── source_registry.py # Dataset alias map / provenance metadata (stdlib-only)
│ │ ├── glorys_subchunk.py # Just-in-time kerchunk subchunking for GLORYS
│ │ ├── settings.py # Template rendering
│ │ └── namelist_model.py # RunTimeSettings + build_namelist
│ └── catalog/ # Bundled spec catalog (+ BlueprintCatalog API)
│ ├── ModelSpec/{model}/model.yaml # Code repos, templates, settings, defaults
│ ├── DomainSpec/{grid}/Domain.yaml # Grid definitions
│ ├── ForcingSpec/{name}/Forcing.yaml # Forcing source configurations
│ ├── OutputSpec/{name}/Output.yaml # Output configurations
│ └── blueprints/ # Example blueprints (bundled, read-only layer;
│ # user saves go to the user catalog layer instead)
├── templates/ # Render templates (cppdefs.opt.j2, marbl_in), decoupled
│ # from ModelSpec — fetched by ForgeExecutor via C-Star's
│ # AdditionalCode
├── legacy/ # Deprecated pre-wizard tooling: notebook workflows,
│ # the nb_engine runner, and legacy-layout blueprints
├── docs/ # Documentation
└── README.mdNote glorys_subchunk.py is live (called from input_data.py) but is not
in the boundary guard’s _FORGE_APP_MODULES list — a known guard gap (§6).
3. ForgeBlueprint — the forge blueprint¶
Defined in cstar_forge/forge/forge_blueprint.py, which subclasses
cstar.orchestration.models.Blueprint — this is what makes forge a real C-Star
application (see §3a), not just a Pydantic model that happens to carry an
application string. It’s the ONLY cstar import in this module — everything
else in forge/ stays free of cstar_forge’s authoring/host layer (see §4) —
so it remains lightweight (no ROMS/MARBL build, no roms-tools); cstar-ocean is
a required pip dependency of this package regardless (see pyproject.toml).
Top-level shape: forge_blueprint_version (int, bump only on breaking change;
currently 4) · application (="forge", C-Star app discriminator, required by the
Blueprint base) · name/description (required top-level fields on the Blueprint
base; name is the single user-editable canonical name — casename/working_dir/
B_{name}.yaml/netCDF stems all derive from it) · run (start/end date,
model_reference_date) · domain (grid_name, grid_kwargs, topography_source,
open_boundaries, partitioning, nesting) · forcing (flat: initial_conditions,
surface/boundary/tidal/river lists, cdr_forcing, resolved_datasets) · datasets
(host-independent list of resolved dataset keys) · model_settings (flat dict: cppdefs +
~35 namelist sections) · code (roms/marbl repos + templates_compile_time/_run_time
repo refs) · composition (which catalog specs produced this + overrides layer) ·
provenance (generated_at, content_hash, notes). The Blueprint base also adds
state/schema_version (its own versioning metadata, distinct from
forge_blueprint_version) and injects a $schema key on serialization (stripped back
out on load).
Older blueprint files load transparently: a model_validator(mode="before")
(migrate_forge_blueprint_data) migrates v2/v3 layouts (removed identity
sub-model, removed ensemble_id) to the current shape, reproducing derived
names bit-for-bit. model_name/grid_name live in
composition.model.name/domain.grid_name; grid_name is results-affecting —
SourceData keys cache filenames off it.
working_dir(default~/cstar/_forge_bp_runs) is the single per-run artifact root — everything the executor produces lands under it. It’s host/location, not results-affecting, so it’s excluded fromcontent_hash. Redeclared asstr(theBlueprintbase’s isPath) to preserve sentinel expansion — seeForgeBlueprint._resolve_out_dir, which overrides the base’s eagerexpanduser()/resolve()for exactly this reason.content_hash()— sha256 over everything exceptforge_blueprint_version,name,description,composition,provenance,working_dir,state,schema_version,$schema(see_HASH_EXCLUDE), plus each code repo’slocationfield (the fetch address; onlycommit/branch/directory/filesare results-affecting). Stamped onto_yaml;verify_content_hashwarns (doesn’t block) on a mismatched hand-edit at load.
3a. Forge as a real C-Star application¶
cstar_forge/forge/app.py (NOT part of the forge/ boundary guarded by §4/
test_forge_app_boundary.py — like run.py and cli.py, it’s disposable
host-resolution glue) defines the pieces the
C-Star custom-applications contract
requires:
ForgeRunner(BlueprintRunner[ForgeBlueprint])—run()delegates tocstar_forge.run.process(host resolution) →process_forge_blueprint→ensure_source_data/generate_inputs/configure_build, then reportsExecutionStatus.COMPLETED. Scope: generates inputs and emits the downstreamroms_marblblueprint (B_{name}.yaml), then stops — the existingroms_marblapplication consumes that blueprint separately.ForgeApplication—@register_application-decoratedApplicationDefinitionwiringForgeBlueprint+ForgeRunnertogether undername = "forge".
Discovered through the cstar.applications entry-point group that cstar-forge
declares in pyproject.toml:
[project.entry-points."cstar.applications"]
forge = "cstar_forge.forge.app"C-Star imports that module the first time an application: forge blueprint is
resolved, so an installed cstar-forge is the whole requirement — no environment
variables, and it holds in spawned scheduler jobs too. This is C-Star’s only
mechanism for out-of-tree applications (the older CSTAR_APP_MODULES env var was
removed); a name already used by a built-in C-Star application cannot be claimed
this way.
Three ways to run a forge blueprint:
cstar blueprint run forge_blueprint.yaml— the app-framework path (defaults only; no forge-specific options), the no-frills front door. Resolvesapplication: forgevia the entry point above, so it needs a C-Star release that consults that group; on an older C-Star use one of the entries below.cstar forge run forge_blueprint.yaml— thecli.pytyper sub-app, registered via thecstar.clientry-point group (requires a C-Star release with the discovery hook); a full-option argv passthrough torun.main. Reach for this for per-run optionscstar blueprint rundoesn’t expose (stage selection,--clobber, dask tuning,--only-inputs, verbosity).python -m cstar_forge.run forge_blueprint.yaml— the module CLI both of the above ultimately reach; always available.
The app lives in this repo (not relocated into the C-Star repo) — deliberate, per §1’s target: the blueprint/executor design is still iterating, so relocation stays a later step.
4. The call chain end to end¶
Authoring (catalog → resolver/wizard → blueprint):
wiz = ForgeBlueprintWizard()(forge_blueprint_wizard.py) — scans the catalog viadomain_catalog.default_catalog, populates dropdowns; entries from lower layers (e.g. the bundled catalog) are shown with a(bundled)badge. The notebook entry point is actuallyForgeBlueprintWizardApp(), a thin wrapper that shows a catalog-location bar above the wizard (auto-loads the default layered stack — your writable~/cstar-forge-data/catalog/CSTAR_FORGE_CATALOGlayer over the read-only bundled catalog; Reload rebuilds a fresh wizard against a different single local path/"local"/GitHub URL/http URL, or severalos.pathsep-separated locations to build a newLayeredCatalog, keeping the previous wizard on failure). Saves (blueprints, workplans) and catalog registrations land in the stack’s writable top layer — never inside the installed package.User picks a domain →
_on_domain()prefills grid/boundaries/partitioning/dates fromcatalog.domain_data(name).Every edit →
_rebuild()→build_forge_blueprint(**self._gather())(forge_blueprint_resolve.py) — reads the single consolidatedmodel.yamldirectly as a dict (no Pydantic here;code+ flatmodel_settings, no embedded forcing/output defaults — a ForcingSpec and OutputSpec must always be supplied explicitly), resolves dataset keys viasource_registry, computes pure-derived settings (CFLdt,v_sponge, etc.), returns aForgeBlueprint.wiz.config.to_yaml(path)writes the portableforge_blueprint.yaml.
Execution (blueprint → engine → executor), same machine or a different one:
5. cstar blueprint run forge_blueprint.yaml (or cstar forge run …) —
resolves the host via cstar_forge.config.resolve_host() (machine tag,
source_data_cache, working_dir override).
6. forge.forge_blueprint_engine.process_forge_blueprint(cfg, host, ...) builds a
ForgeExecutor via ForgeExecutor.from_forge_blueprint(cfg, host) and drives:
ensure_source_data() → generate_inputs() → configure_build().
7. Outputs land under host.working_dir: input NetCDFs, namelist.nml, cppdefs.opt,
and the emitted downstream roms_marbl blueprint YAML (B_{name}.yaml, persisted
once by configure_build() — there is no per-stage blueprint file).
ForgeExecutor never imports cstar_forge.config/catalog/domain_catalog/
forge_blueprint_resolve/forge_blueprint_wizard — verified both by grep and by
tests/test_forge_app_boundary.py (an AST-based guard with an empty, actively-enforced
violation allowlist). namelist_model.py and util.py are same-package siblings
inside forge/ and are covered by the guard’s _FORGE_APP_MODULES list.
4a. Versioned namelist schemas (ucla-roms 0.5.0+)¶
ucla-roms 0.5.0 made its first breaking namelist change (nrpf_rst removed from
&BASIC_OUTPUT_SETTINGS; &PARTICLES_SETTINGS output_period/nrpf renamed to
output_period_particles/nrpf_particles). C-Star versions the namelist schema
by ucla-roms release (cstar.roms.namelist: RomsNamelist for < 0.5.0,
RomsNamelistV0_5_0 for >= 0.5.0, selected by namelist_schema_for_ref(ref) —
semver tags select exactly; branch names/hashes warn and fall back to the latest
schema). Forge mirrors this in namelist_model.py: RunTimeSettings (legacy)
vs RunTimeSettingsV0_5_0, selected by run_time_settings_for_ref(roms_ref),
where roms_ref is the blueprint’s pinned code.roms.commit (threaded
resolver → executor → write_roms_namelist). C-Star’s registry is the single
source of version-boundary truth — forge only maps its result to the matching
settings class. The forge settings vocabulary is version-stable: YAML keys
(particles.output_period, particles.nrpf) don’t change; only the
serialization_alias to namelist names differs per version, and nrpf_rst
(still present in the shared OutputSpec/standard) is silently ignored for
0.5.0+ models via extra="ignore". One ModelSpec per tagged ucla-roms release:
roms-marbl-0.5-default pins 0.5.0; older specs stay fixed and keep emitting
byte-identical legacy namelists.
ucla-roms 0.5.0 also added a run-start precheck (check_output_divides_rst):
each enabled output stream’s nrpf × output_period must evenly divide
output_period_rst (vacuous for monthly restarts / a 0 period). Three bundled
OutputSpecs conform for every stream — daily-restarts (the wizard default,
see _DEFAULT_OUTPUT_SPEC), weekly-restarts, and monthly-restarts
(upstream’s own convention: monthly_restarts=T, output_period_rst=0).
OutputSpec/standard predates the precheck and is kept unchanged for
blueprints that reference it — enabling its his/avg streams under a 0.5.0+
model trips the precheck. A guard test
(test_bundled_output_specs_satisfy_roms_divides_rst_precheck) pins the
conforming specs, including roms-marbl-0.5-default’s ModelSpec-owned
sponge/particles streams. The nesting extract stream is resolve-time-derived
(child DomainSpec metadata period × a seeded nrpf), so it’s enforced at
authoring time instead: check_extract_divides_rst (namelist_model.py), called
from the resolver and gated to >= 0.5.0 pins.
5. models.py vs forge/forge_blueprint.py¶
The forcing/IC item models (BoundaryForcingItem, SurfaceForcingItem,
InitialConditions, OpenBoundaries, etc.) are defined once, in
forge/forge_blueprint.py; models.py imports and re-exports them — single
source of truth, no duplication. What models.py owns is the model.yaml
wrapper shape (ModelSpec, ModelCode, ModelTemplates, load_models_yaml)
used by domain_catalog.load_model_spec() for full Pydantic validation at
catalog-registration time — a heavier, separate path from the resolver’s
plain-dict read of the same file. cstar_forge.forge never imports
cstar_forge.models.
What guards drift today: tests/test_roms_tools_coverage.py (roms-tools option
coverage) and a resolver/executor settings-parity assertion in
test_forge_blueprint.py.
6. Known gaps / open items¶
Template staging has no CI coverage for the cross-repo flat-staging contract (
ForgeExecutor._stage_templates, executor.py ~L1036). Rendering silently assumes C-Star’sAdditionalCodestages filtered files flat; only manually verified against the real remote. A@pytest.mark.slownetwork test staging from the real repo would close this.Templates are re-fetched every
configure_build(rmtree + re-clone underworking_dir/templates/<stage>), not cached like source data / code. A commit-keyed template cache (mirroringsource_data_cache) would fix this.glorys_subchunk.pyis outside the §4 boundary guard — live code called frominput_data.pybut absent from_FORGE_APP_MODULES, so boundary violations there would go uncaught.Forge app relocation into C-Star not yet done (see §3a) — deliberate; relocation is a follow-on once the blueprint/executor design settles. Also open:
ForgeRunner.run()calls the synchronous, heavyprocess_forge_blueprintinline on the event loop rather than viaasyncio.to_thread— fine for a first cut, candidate refinement later.No real-generated-data integration test (actual GLORYS/ERA5/TPXO network fetch with no roms-tools mocking) — the golden tests below mock roms-tools construction classes.
7. Golden fixtures¶
Two committed goldens pin the resolved-settings and namelist contracts (treat any diff as a behavior change to justify, not noise):
Settings-level:
test_golden_model_settings_test_tiny(test_forge_blueprint.py) diffs resolvedmodel_settingsagainsttests/fixtures/golden_model_settings_test-tiny.json. No regeneration hook — update manually.Byte-exact namelist:
tests/test_core.py::TestGoldenNamelist:: test_golden_namelist_test_tinydrives the realgenerate_inputs()→configure_build()chain (realwrite_roms_namelist; only roms-tools construction classes are mocked) and diffs the renderednamelist.nmlagainsttests/fixtures/golden_namelist_test-tiny.nml(host-rooted absolute paths normalized to a<WORKDIR>token). Two sibling tests pin the versioned-namelist schemas against the same test-tiny domain/forcing/output setup:test_golden_namelist_test_tiny_roms050(roms-marbl-0.5-default,golden_namelist_test-tiny-roms050.nml) andtest_golden_namelist_test_tiny_roms060(roms-marbl-0.6-default, adds&PIO_SETTINGS,golden_namelist_test-tiny-roms060.nml). Regenerate one at a time viaUPDATE_GOLDEN=1 pytest tests/test_core.py -k <test name>(the run intentionally fails after writing; rerun without the env var to confirm). To select only the legacy test, use-k "golden_namelist_test_tiny and not roms050 and not roms060"-- a bare-k golden_namelist_test_tinymatches all three.
Both fixtures resolve paths via cstar_forge.__file__, so they need the
editable install.