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:
- You create a shielding Design.
- You freeze it as a Case.
- You choose the number of histories and a random seed.
- You run the Case.
- The simulation creates output files.
- RAMAL-EBX checks those files.
- 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