Skip to content

Chapter 4: Immutable Case, Job, Attempt, and Result Lifecycle

In Chapter 3: PHITS Design Authoring Pipeline, we saw how RAMAL-EBX turns editable PHITS information into validated input.

Now we will follow that input through an actual simulation run.

Imagine this situation:

  1. You create a shielding Design.
  2. You freeze it as a Case.
  3. You choose the number of histories and a random seed.
  4. You run the Case.
  5. The simulation creates output files.
  6. RAMAL-EBX checks those files.
  7. Only then does it publish a Result for analysis.

This is the Immutable Case, Job, Attempt, and Result Lifecycle.

The main idea

RAMAL-EBX separates preparation, execution, and verification.

The four important records are:

Record Simple meaning Laboratory analogy
Case Frozen simulation input and result contract Sealed experiment protocol
Job Run settings Scheduled experiment
Attempt One execution of a Job Lab notebook for one run
Result Verified output package Accepted measurement package

The lifecycle looks like this:

flowchart LR
    A[Editable Design] --> B[Immutable Case]
    B --> C[Job settings]
    C --> D[Execution Attempt]
    D --> E[Artifact verification]
    E --> F[Published Result]

The central rule is:

A Result is not published merely because PHITS finished. Its required files must also pass integrity checks.

Why immutability matters

Suppose a Design uses:

Particle: electron
Energy: 10 MeV
Histories: 1,000,000
Seed: 42

You create a Case and start a simulation. While the simulation is running, someone edits the Design:

Particle: proton
Energy: 20 MeV

If the running simulation depended directly on the editable Design, the input and the saved record could become confusingly different.

RAMAL-EBX avoids this problem:

Design
  ↓ freeze
Case: electron, 10 MeV
  ↓ run
Attempt uses Case input

The Case does not change when the Design changes.

This makes it possible to answer:

“Exactly what input produced this Result?”

The four records

1. Case: the frozen protocol

A Case_Document stores the exact simulation input and the contract for the expected output.

Case_Document :: struct {
    project_id: string
    case_id: string
    input_path: string
    ramal_input: string
    result_contract: Result_Contract
}

The ramal_input field contains the PHITS input owned by the Case.

The input_path points to the solver-ready copy:

cases/shield-case/input.inp

The result_contract describes the output that must be produced:

  • Required VTK file.
  • Required report.
  • Required uncertainty file.
  • Expected quantity.
  • Expected unit.
  • Expected particles.
  • Expected region reports.

The Case is like a sealed protocol. It says both:

“What should be simulated?”
“What output must be accepted?”

2. Job: the scheduled run

A Job_Document stores execution settings.

Job_Document :: struct {
    case_id: string
    job_id: string
    maxcas: i32
    maxbch: i32
    rseed: i32
    execution_target: string
    status: string
}

The important settings are:

  • maxcas: PHITS history setting.
  • maxbch: PHITS batch setting.
  • rseed: random seed.
  • execution_target: local or remote execution.
  • status: planned, running, succeeded, failed, or stopped.

A Job does not replace the Case. It tells RAMAL-EBX how to execute that Case.

3. Attempt: one execution

A Job can be run more than once.

For example:

Job: shielding-run
Attempt a0001: failed
Attempt a0002: succeeded

Each run receives a new Attempt ID:

a0001
a0002
a0003

An Attempt stores execution state inside the Job:

Attempt_Document :: struct {
    attempt_id: string
    status: string
    phase: string
    execution_input_path: string
    attempt_record_path: string
    result_id: string
    return_code: i32
}

An Attempt is like a lab notebook. It records what happened during one execution without changing the Case.

4. Result: accepted output

A Result_Document is created only after verification succeeds.

Result_Document :: struct {
    project_id: string
    case_id: string
    job_id: string
    result_id: string
    attempt_id: string
    status: string
    completed_at_utc: string
}

The Result links back to:

Project → Case → Job → Attempt → Result

This relationship makes the Result traceable.

A complete example

Suppose we have:

Case: case-shield-001
Job:  job-001
Attempt: a0001
Result: result-0001

The files might look like this:

cases/case-shield-001/
├── case-shield-001.ramal-case
├── input.inp
└── jobs/job-001/
    ├── job-001.ramal-job
    ├── attempts/a0001/
    └── result-0001.ramal-result

The Attempt directory contains the actual execution artifacts:

attempts/a0001/
├── scene.phits.inp
├── manifest.json
├── dose.vtk
├── dose.out
├── dose_err.vtk
└── dose_err.out

The Result record points to the Attempt. It does not need to duplicate every output file.

Creating an immutable Case

A Case is created from a clean Design.

if app.design_scene.dirty {
    return false
}
input, contract, ok := case_input_from_design(app)

The Design must not have unsaved changes. RAMAL-EBX generates the input and the Result contract together.

This is important because the Case must freeze one complete version of the scientific setup.

The generated input is then checked by importing it again:

if !design_phits_import_parse(&verified, input) ||
   !design_phits_import_resolve(&verified) {
    return false
}

RAMAL-EBX confirms that the generated input can be parsed and its references can be resolved.

The result is a validated Case input, not merely a text file that happened to be generated.

The Case owns its exact input

When the Case is saved, RAMAL-EBX writes:

cases/case-shield-001/input.inp

The .inp file is the solver-compatible input.

The .ramal-case record stores the managed version and result contract.

Later, case_document_read checks that the two agree:

input_data, err := os.read_entire_file(input_path, context.temp_allocator)
if err != nil || string(input_data) !=
   design_solver_input(document.ramal_input) {
    return false
}

If the input file was changed manually, the Case is rejected.

This prevents a Result from being associated with the wrong simulation input.

Atomic Case creation

Case creation uses a staging directory:

cases/.case-shield-001.staging/

RAMAL-EBX writes the files there first:

staging/
├── input.inp
└── case-shield-001.ramal-case

Only after everything succeeds does it rename the directory:

if os.rename(staging, final_directory) != nil {
    return false
}

The Case becomes visible as one complete unit.

flowchart LR
    A[Create staging directory] --> B[Write input]
    B --> C[Write Case record]
    C --> D[Rename staging directory]
    D --> E[Case becomes available]

If an earlier step fails, the incomplete staging directory is removed.

Creating a Job

Once the Case is ready, a Job can be created.

job_create(
    &app.project,
    "case-shield-001",
    "One million histories",
    1000000,
    10,
    42,
    0,
)

This creates a Job with:

maxcas = 1,000,000
maxbch = 10
rseed  = 42

The Job begins with status:

planned

The Job is stored below its Case:

cases/case-shield-001/jobs/job-001/job-001.ramal-job

The Job validates that:

  • The Case exists.
  • The history values are positive.
  • The random seed is valid.
  • The execution target is valid.
  • The Project relationship is correct.

Why the random seed belongs to the Job

The Case describes the scientific input. The Job describes one run configuration.

The random seed belongs to the Job because two Jobs can use the same Case with different seeds:

Case: shielding geometry and source
Job A: seed 42
Job B: seed 43

This allows repeated or replicated runs while preserving the exact Case input.

RAMAL-EBX can also create deterministic seeds from the Project, Case, Job, and replica number:

identity := fmt.tprintf(
    "%s:%s:%s:replica-%d",
    project_id, case_id, job_id, replica_index,
)
seed := phits_deterministic_seed("ramal-ebx-run", identity)

The same identity produces a predictable seed unless a collision requires adjustment.

Starting an Attempt

When a Job starts, RAMAL-EBX creates the next Attempt ID:

attempt_id := fmt.aprintf(
    "a%04d",
    len(job_document.attempts) + 1,
)

For the first run, the ID is:

a0001

The Attempt is added to the Job as preparing:

Attempt_Document {
    attempt_id = "a0001",
    status = "preparing",
    phase = "preparing",
}

The Job status also becomes:

preparing

The Attempt directory is:

cases/case-shield-001/jobs/job-001/attempts/a0001/

The execution input

Before PHITS runs, RAMAL-EBX prepares a specialized input.

The input starts from the immutable Case:

Case input
  ↓ apply Job histories
  ↓ apply Job batches
  ↓ apply Job seed
Attempt execution input

The preparation command receives the Case and Job settings:

append(&command,
    "--prepare-attempt",
    "--attempt-id", attempt_id,
    "--input", case_input,
    "--specialize-maxcas", fmt.tprintf("%d", job.maxcas),
)

The resulting file is stored as:

attempts/a0001/scene.phits.inp

The Case remains unchanged. The Attempt receives its own execution copy.

Attempt phases

An Attempt passes through phases:

preparing → running → verifying → idle

For remote execution, additional phases can appear:

submitting → watching → transferring

The phase describes what the application is doing now.

The status describes the overall outcome:

preparing
running
succeeded
failed
stopped

A successful Attempt ends like this:

status = succeeded
phase  = idle
result_id = result-0001
return_code = 0

A failed Attempt has no Result:

status = failed
phase  = idle
result_id = empty

Local execution flow

For a local Job, the flow is:

sequenceDiagram
    participant User
    participant App
    participant Runner
    participant PHITS
    participant Files

    User->>App: Start Job
    App->>Runner: Prepare Attempt
    Runner->>Files: Write execution input
    App->>Runner: Execute Attempt
    Runner->>PHITS: Run Case input
    PHITS->>Files: Write artifacts
    App->>Files: Verify artifacts
    App-->>User: Publish Result or report failure

The application first prepares the Attempt, then starts PHITS through the runner.

If preparation succeeds, the Attempt becomes running.

Remote execution flow

Remote execution adds submission and transfer steps:

prepare Attempt
    ↓
submit to remote PHITS
    ↓
watch progress
    ↓
wait for terminal state
    ↓
transfer Attempt artifacts
    ↓
verify locally
    ↓
publish Result

The remote sidecar records progress and diagnostics. RAMAL-EBX checks that the sidecar belongs to the expected:

Project
Case
Job
Attempt
execution target

This prevents status information from one remote run being applied to another.

Stopping a run

Stopping a simulation changes only the Attempt and Job state.

simulation_fail(
    app,
    .Stopped,
    "Case Attempt stopped; the immutable Case remains unchanged",
)

The Attempt becomes:

status = stopped
phase  = idle

The Case is still available for another Job or another Attempt.

This is useful because an interrupted run should not damage the prepared scientific input.

What is an artifact?

An artifact is a file produced by the simulation.

Examples include:

dose.vtk
dose.out
dose_err.vtk
dose_err.out
manifest.json

The Case’s Result contract says which roles are required.

For example:

Role Expected file
Dose field dose.vtk
Dose report dose.out
Uncertainty field dose_err.vtk
Uncertainty report dose_err.out

Region analysis may require additional files:

ramal-region-region-001.out

The exact filenames come from the Case contract, not from a hardcoded scientific assumption.

The Attempt manifest

The manifest describes the artifacts found in an Attempt.

Attempt_Manifest :: struct {
    attempt_id: string
    status: string
    result_roles: Result_File_Roles
    region_reports: []Result_Region_Report_Role
    artifacts: []Attempt_File_Record
}

The manifest must say:

Which Attempt produced these files?
Which files are the dose outputs?
Which files are region reports?

RAMAL-EBX compares the manifest with the Case’s Result contract.

if manifest.result_roles != case_document.result_contract.required_roles {
    return false
}

A mismatch means the output does not belong to this Case contract.

Publishing a Result

A Result is published by result_publish.

The function first reads and verifies the Case and Job:

if !case_document_read(project, case_id, &case_document, verify_input = true) ||
   !job_document_read(project, case_id, job_id, &job_document) {
    return false
}

It then reads the Attempt manifest:

if !attempt_manifest_read(
    project, case_id, job_id, attempt_id, &manifest,
) {
    return false
}

The Attempt must be preparing or already succeeded:

if attempt == nil ||
   attempt.status != "running" &&
   attempt.status != "succeeded" {
    return false
}

The function checks every required artifact role.

for artifact in manifest.artifacts {
    if artifact.path == required_name {
        found = true
    }
}

Missing, nested, or unsafe artifact paths are rejected.

Creating the Result record

After all checks pass, RAMAL-EBX chooses a new Result ID:

result-0001

It creates a completed Result document:

Result_Document {
    case_id = case_id,
    job_id = job_id,
    result_id = result_id,
    attempt_id = attempt_id,
    status = "completed",
}

The Result record is written exclusively. It must not overwrite an existing Result.

Then the Attempt is updated:

Attempt status: succeeded
Attempt result_id: result-0001
Job status: succeeded

If updating the Job fails, RAMAL-EBX removes the new Result record. This prevents a Result from being published while its Attempt still appears incomplete.

What integrity verification checks

When result_document_read opens a Result, it checks:

  • The Result belongs to the active Project.
  • The Case ID matches the requested Case.
  • The Job ID matches the requested Job.
  • The Result ID matches its filename.
  • The Attempt exists in the Job.
  • The Attempt succeeded.
  • The Attempt points to this Result.
  • The manifest is succeeded.
  • Manifest roles match the Case contract.
  • Required artifact files exist when verification is requested.
if attempt.status != "succeeded" ||
   attempt.result_id != loaded_document.result_id {
    return false
}

This protects the relationship between the records.

Loading a dose Result

Opening a Result for analysis is another staged operation.

RAMAL-EBX first verifies the Result:

if !result_document_read(
    &app.project,
    case_id,
    job_id,
    result_id,
    &document,
    verify_record = true,
) {
    return false
}

It then resolves the artifact roles:

roles, region_reports, ok :=
    attempt_result_roles_resolve(attempt_path)

The dose field is loaded into a temporary state:

candidate_field := Dose_Field_State{}
dose_field_init(&candidate_field)

Only after loading succeeds does RAMAL-EBX replace the currently open result.

This means a damaged new Result does not destroy the previous valid Result in the interface.

Validating the VTK dose field

The VTK reader checks the grid structure.

It expects:

DATASET RECTILINEAR_GRID
DIMENSIONS
X_COORDINATES
Y_COORDINATES
Z_COORDINATES
CELL_DATA
FIELD

It also checks that:

  • Dimensions are valid.
  • Coordinates increase strictly.
  • The number of values matches the grid.
  • Values are not NaN or infinity.
  • At least one usable field page exists.
if dataset != "RECTILINEAR_GRID" ||
   !dose_vtk_seek_token(&scanner, "DIMENSIONS") {
    return false
}

The result is loaded only if the file has a valid rectangular voxel grid.

Matching dose and uncertainty files

The dose field and uncertainty field must describe the same grid.

RAMAL-EBX checks:

if dose.cell_count != uncertainty.cell_count ||
   len(dose.pages) != len(uncertainty.pages) {
    return false
}

It also compares every coordinate.

The uncertainty values are then attached to the dose pages:

dose value + relative error

This prevents uncertainty data from a different mesh from being paired with the dose data.

Matching the report

The report must also agree with the Case contract.

RAMAL-EBX checks:

  • Expected title.
  • Expected multiplier.
  • Expected unit.
  • Expected particle list.
  • Expected number of field pages.
  • Expected total-page relationship.
if !strings.contains(report_text, expected_title) {
    return false
}

For particle pages, the output must match the expected particle order.

For example:

Expected: electron photon all
Actual:   electron photon all

A mismatch causes verification to fail.

Checking particle totals

If the output includes an all page, RAMAL-EBX checks that it matches the sum of the individual particle pages.

all ≈ electron + photon

A small numerical tolerance is allowed because floating-point calculations are not perfectly exact.

If the difference is too large, the Result is rejected.

This catches incomplete or inconsistent output data.

Opening the Result in Analyze

After the dose field is valid, RAMAL-EBX reconstructs the geometry from the Attempt’s PHITS input.

It removes the [Source] section and imports the remaining geometry:

if strings.equal_fold(block.header, "[Source]") {
    continue
}

The reconstructed geometry is placed into a separate Result scene.

The interface then reports:

Completed Result opened with its immutable Case geometry

The geometry shown during analysis comes from the Attempt’s frozen execution input, not from the current editable Design.

Working with the loaded dose field

After a Result is opened, RAMAL-EBX can inspect the voxel data.

A sample can be taken at a position:

sample, ok := dose_field_sample_at_position(
    &field,
    &page,
    {0, 0, 10},
)

The output includes:

Requested position
Voxel center
Voxel index
Dose value
Relative error

RAMAL-EBX can also find the hottest voxel:

hotspot, ok := dose_field_hotspot(&field, &page)

Or extract values along a profile:

xs, ys, errors, ok :=
    dose_field_profile_values(&field, &page, 2, position)

These operations are safe because the field was checked before it became active.

Visibility limits for large fields

A large voxel field may contain millions of voxels. Drawing all of them interactively could be slow.

RAMAL-EBX calculates a visibility threshold:

maximum value
    ↓
visible decades
    ↓
minimum visible value

Only values above the threshold are considered for display.

If too many voxels qualify, RAMAL-EBX samples them using a stride:

if result.eligible_count > field.max_visible_voxels {
    result.sample_stride =
        (result.eligible_count + field.max_visible_voxels - 1) /
        field.max_visible_voxels
}

This affects visualization only. It does not modify the stored Result.

Catalogs show only valid records

The Case, Job, and Result catalogs load records through their validation functions.

For example, the Result catalog calls:

if !result_document_read(
    project,
    case_id,
    job_id,
    result_id,
    &document,
) {
    continue
}

Invalid files are skipped instead of being shown as usable Results.

This makes the catalogs reflect records that RAMAL-EBX can actually trust.

Common failure examples

The Case input was edited

Problem:
cases/case-shield-001/input.inp no longer matches the Case record.

Outcome:
Case verification fails.

A required artifact is missing

Problem:
dose_err.vtk was not produced.

Outcome:
No Result is published.

The report uses the wrong unit

Problem:
The Case expects Gy, but the report contains MeV.

Outcome:
Dose loading fails.

The uncertainty grid differs

Problem:
The uncertainty VTK file has different coordinates.

Outcome:
The Result is rejected.

An Attempt stopped

Problem:
The user cancels the simulation.

Outcome:
Attempt becomes stopped; Case remains unchanged.

These failures are useful. They prevent questionable data from silently reaching analysis.

The complete lifecycle

The full process can be summarized as:

flowchart TD
    A[Editable Design] --> B[Create Case]
    B --> C[Validate exact input and Result contract]
    C --> D[Create Job]
    D --> E[Store histories, batches, seed, target]
    E --> F[Create Attempt]
    F --> G[Prepare execution input]
    G --> H[Run locally or remotely]
    H --> I[Collect artifacts]
    I --> J[Verify manifest and output files]
    J --> K[Publish completed Result]
    K --> L[Open verified data in Analyze]

At every step, the next record depends on the previous record being valid.

Where this code lives

The main implementation is divided into these files:

case.odin handles:

  • Case records.
  • Frozen PHITS input.
  • Result contracts.
  • Case validation.
  • Case creation.
  • Case storage.

job.odin handles:

  • Job records.
  • Histories and batches.
  • Random seeds.
  • Execution targets.
  • Attempt state updates.
  • Job catalogs.

attempt.odin handles:

  • Attempt IDs.
  • Attempt paths.
  • Attempt manifests.
  • Artifact role resolution.
  • Verification summaries.

simulation.odin handles:

  • Local execution.
  • Remote submission.
  • Progress watching.
  • Cancellation.
  • Recovery.
  • Result publication.

result.odin handles:

  • Result records.
  • Result validation.
  • Result catalogs.
  • Result publishing.
  • Opening verified Results.

dose_field.odin handles:

  • VTK parsing.
  • Dose and uncertainty matching.
  • Report checks.
  • Voxel sampling.
  • Hotspots.
  • Profiles.
  • Slices.
  • Display visibility.

The runner used by the application is:

tools/run_case_attempt.py

The GUI remains the supported entry point for creating Cases, running Jobs, monitoring Attempts, and opening Results.

A beginner’s mental model

Think of the lifecycle as a laboratory:

Design
    A changeable experiment idea

Case
    A sealed protocol

Job
    Instructions for one scheduled run

Attempt
    Notebook recording what happened

Result
    Measurement package accepted after inspection

The most important relationship is:

A Design can change.
A Case cannot.
A Job chooses run settings.
An Attempt records one run.
A Result exists only after verification.

Conclusion

You learned that:

  • A Case freezes exact PHITS input and the expected Result contract.
  • A Job stores histories, batches, seed, replica information, and execution target.
  • An Attempt records one execution and its lifecycle state.
  • Local and remote runs use the same logical Attempt model.
  • Artifacts are described by a manifest.
  • Results are published only when required files match the Case contract.
  • Dose, uncertainty, grid, particle, report, and unit checks protect analysis.
  • Failed or stopped Attempts do not change the immutable Case.
  • Result loading uses temporary state so an invalid Result cannot replace a valid one.
  • Analysis uses geometry reconstructed from the frozen Case execution input.

This lifecycle turns a simulation from “some files in a folder” into a traceable and reproducible experiment.

Next, we will follow the records and verified outputs into the Research Provenance and Evidence Pipeline.


Generated by AI Codebase Knowledge Builder