Skip to content

sheet

sheet

Sheet entity model — the first-class execution unit in Marianne.

A Sheet carries everything a musician needs to execute: identity, instrument, prompt, context injection, validations, and timeout. Sheets are constructed at setup time from the parsed score and handed to the baton for dispatch.

The music metaphor: sheet music contains everything the musician needs to play their part — the notes, the key, the tempo, the dynamics. They don't need to see the full score. They need their sheet.

What's NOT on the Sheet: dependencies, skip_when, retry policy, execution state, cost limits. Those belong to the baton and state systems. The Sheet is execution data; the baton owns coordination logic.

Prompt rendering is deferred. The template stays unrendered because cross-sheet context ({{ previous_outputs[2] }}) only exists after earlier sheets complete. The baton renders at dispatch time.

Attributes

Classes

Sheet

Bases: BaseModel

A fully self-contained execution unit. Everything a musician needs.

Constructed at setup time from the parsed score YAML. Immutable after construction — the baton dispatches Sheet entities as-is.

Identity

num: Concrete sheet number (1-indexed, globally unique within a job) movement: Which movement this sheet belongs to (was: stage) voice: Which voice in a harmonized movement (was: instance), None if solo voice_count: Total voices in this movement (was: fan_count)

Execution

instrument_name: Resolved instrument name (e.g. 'gemini-cli') instrument_config: Overrides for instrument defaults (model, timeout, etc.) prompt_template: Raw Jinja2 template (rendered at dispatch time) template_file: External template file (alternative to inline) variables: Static template variables from the score timeout_seconds: Per-sheet execution timeout

Context injection

prelude: Shared context injected into all sheets cadenza: Per-sheet context injection prompt_extensions: Additional prompt directives

Acceptance criteria

validations: What "done" means for this sheet

Functions
template_variables
template_variables(total_sheets, total_movements)

Build the full template variable dict for Jinja2 rendering.

Merges built-in variables (identity, workspace, instrument) with the score's custom variables. Built-in variables take precedence over custom variables to prevent accidental overrides.

New and old terminology aliases are both provided — old names (stage, instance, fan_count, total_stages) are kept forever for backward compatibility.

Parameters:

Name Type Description Default
total_sheets int

Total concrete sheet count in the job.

required
total_movements int

Total movement count in the job.

required

Returns:

Type Description
dict[str, Any]

Dict of all template variables for Jinja2 rendering.

Source code in src/marianne/core/sheet.py
def template_variables(
    self,
    total_sheets: int,
    total_movements: int,
) -> dict[str, Any]:
    """Build the full template variable dict for Jinja2 rendering.

    Merges built-in variables (identity, workspace, instrument) with
    the score's custom variables. Built-in variables take precedence
    over custom variables to prevent accidental overrides.

    New and old terminology aliases are both provided — old names
    (stage, instance, fan_count, total_stages) are kept forever for
    backward compatibility.

    Args:
        total_sheets: Total concrete sheet count in the job.
        total_movements: Total movement count in the job.

    Returns:
        Dict of all template variables for Jinja2 rendering.
    """
    # Start with custom variables (lowest precedence)
    tvars: dict[str, Any] = dict(self.variables)

    # Built-in variables (override custom)
    tvars.update({
        # Core identity
        SHEET_NUM_KEY: self.num,
        "total_sheets": total_sheets,
        "workspace": str(self.workspace),
        "instrument_name": self.instrument_name,
        # New terminology
        "movement": self.movement,
        "voice": self.voice,
        "voice_count": self.voice_count,
        "total_movements": total_movements,
        # Old terminology (aliases — kept forever)
        "stage": self.movement,
        "instance": self.voice,
        "fan_count": self.voice_count,
        "total_stages": total_movements,
    })

    return tvars

Functions

build_sheets

build_sheets(config)

Construct Sheet entities from a JobConfig.

This bridges the old scattered-dict model (SheetConfig with separate dicts for descriptions, cadenzas, prompt_extensions, etc.) to the new first-class Sheet entity. Each concrete sheet in the job gets a fully self-contained Sheet object.

The music metaphor: this is the librarian distributing parts to musicians before the concert. Each musician gets their own sheet with everything they need — they don't need to consult the full score.

Parameters:

Name Type Description Default
config JobConfig

Parsed and validated JobConfig from the score YAML.

required

Returns:

Type Description
list[Sheet]

List of Sheet entities, one per concrete sheet, in sheet_num order.

Source code in src/marianne/core/sheet.py
def build_sheets(config: JobConfig) -> list[Sheet]:
    """Construct Sheet entities from a JobConfig.

    This bridges the old scattered-dict model (SheetConfig with separate
    dicts for descriptions, cadenzas, prompt_extensions, etc.) to the new
    first-class Sheet entity. Each concrete sheet in the job gets a fully
    self-contained Sheet object.

    The music metaphor: this is the librarian distributing parts to musicians
    before the concert. Each musician gets their own sheet with everything
    they need — they don't need to consult the full score.

    Args:
        config: Parsed and validated JobConfig from the score YAML.

    Returns:
        List of Sheet entities, one per concrete sheet, in sheet_num order.
    """
    sheets: list[Sheet] = []
    total_sheets = config.sheet.total_sheets

    # Pre-build reverse lookup for instrument_map: sheet_num -> instrument_name
    instrument_map_lookup: dict[int, str] = {}
    for instr_name, sheet_nums in config.sheet.instrument_map.items():
        for sn in sheet_nums:
            instrument_map_lookup[sn] = instr_name

    for sheet_num in range(1, total_sheets + 1):
        # --- Identity ---
        fan_meta = config.sheet.get_fan_out_metadata(sheet_num)
        movement = fan_meta.stage
        voice: int | None = fan_meta.instance if fan_meta.fan_count > 1 else None
        voice_count = fan_meta.fan_count

        description = config.sheet.descriptions.get(sheet_num)

        # --- Instrument resolution chain ---
        # Priority: per_sheet > instrument_map > movement > score instrument > backend.type
        instrument_config: dict[str, Any] = dict(config.instrument_config)

        # Walk the resolution chain from highest to lowest priority
        resolved_instrument: str | None = None

        if sheet_num in config.sheet.per_sheet_instruments:
            # Highest priority: explicit per-sheet assignment
            resolved_instrument = config.sheet.per_sheet_instruments[sheet_num]
        elif sheet_num in instrument_map_lookup:
            # Batch assignment via instrument_map
            resolved_instrument = instrument_map_lookup[sheet_num]
        else:
            # Check movement-level instrument and config
            if movement in config.movements:
                movement_def = config.movements[movement]
                if movement_def.instrument is not None:
                    resolved_instrument = movement_def.instrument
                # Movement-level instrument_config merges with score-level
                # regardless of whether the movement also overrides the
                # instrument name. A score author should be able to say
                # "same instrument, different model" without repeating
                # the instrument name. F-150: this was gated behind
                # instrument is not None, silently dropping config-only
                # movement overrides.
                if movement_def.instrument_config:
                    instrument_config = {
                        **instrument_config,
                        **movement_def.instrument_config,
                    }
            # Fall through to score-level or backend default
            if resolved_instrument is None:
                if config.instrument is not None:
                    resolved_instrument = config.instrument
                else:
                    resolved_instrument = config.backend.type

        # Resolve score-level instrument aliases to profile names.
        # If the resolved name matches a key in config.instruments, replace
        # it with the profile name and merge the InstrumentDef config.
        if resolved_instrument in config.instruments:
            instrument_def = config.instruments[resolved_instrument]
            resolved_instrument = instrument_def.profile
            if instrument_def.config:
                instrument_config = {**instrument_config, **instrument_def.config}

        instrument_name: str = resolved_instrument

        # Per-sheet instrument config overrides everything
        if sheet_num in config.sheet.per_sheet_instrument_config:
            instrument_config = {
                **instrument_config,
                **config.sheet.per_sheet_instrument_config[sheet_num],
            }

        # --- Timeout ---
        # Resolution: sheet_overrides.timeout_seconds > timeout_overrides > backend.timeout_seconds
        timeout = config.backend.timeout_seconds
        if sheet_num in config.backend.timeout_overrides:
            timeout = config.backend.timeout_overrides[sheet_num]
        if sheet_num in config.backend.sheet_overrides:
            override = config.backend.sheet_overrides[sheet_num]
            if override.timeout_seconds is not None:
                timeout = override.timeout_seconds

        # --- Prompt ---
        prompt_template = config.prompt.template
        template_file = config.prompt.template_file
        variables = dict(config.prompt.variables)

        # --- Context Injection ---
        prelude = list(config.sheet.prelude)
        cadenza = list(config.sheet.cadenzas.get(sheet_num, []))

        # Prompt extensions: score-level + per-sheet
        extensions = list(config.prompt.prompt_extensions)
        per_sheet_ext = config.sheet.prompt_extensions.get(sheet_num, [])
        extensions.extend(per_sheet_ext)

        # --- Validations ---
        # Score-level validations apply to all sheets
        validations = list(config.validations)

        # --- Instrument Fallbacks ---
        # Resolution: per_sheet > movement > score-level
        # Per-sheet replaces (does not merge) inherited chain.
        if sheet_num in config.sheet.per_sheet_fallbacks:
            fallbacks = list(config.sheet.per_sheet_fallbacks[sheet_num])
        elif movement in config.movements and config.movements[movement].instrument_fallbacks:
            fallbacks = list(config.movements[movement].instrument_fallbacks)
        else:
            fallbacks = list(config.instrument_fallbacks)

        sheets.append(
            Sheet(
                num=sheet_num,
                movement=movement,
                voice=voice,
                voice_count=voice_count,
                description=description,
                workspace=config.workspace,
                instrument_name=instrument_name,
                instrument_config=instrument_config,
                instrument_fallbacks=fallbacks,
                prompt_template=prompt_template,
                template_file=template_file,
                variables=variables,
                prelude=prelude,
                cadenza=cadenza,
                prompt_extensions=extensions,
                validations=validations,
                timeout_seconds=timeout,
            )
        )

    return sheets