Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to DataLab-Capsule are documented in this file. The format f

## [Unreleased]

### Added

- **Execution context declared by the computation library:** the recorder now keeps, in the execution context of an activity, the rules that the computation library declares in the description of a call (`context` mapping of `describe_call`), and a replay passes them back to the library, which refuses an activity recorded without a rule it requires or with an unknown one (`unsupported_context`). Sigima uses it to record the convention selecting image pixels in regions of interest, so that activities recorded with an earlier convention are refused explicitly instead of being reported as different. Descriptions without such a mapping (earlier library versions) record nothing more.

### Changed

- Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release; the Pyodide runtime is unchanged.
Expand Down
2 changes: 1 addition & 1 deletion doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ changes = diff_manifests(old.manifest, new.manifest)
| `replay.compare_state(state, facts, file_status=None)` | The single definition of "the same state" (data fingerprint and region of interest), shared by replay preparation, inspection and verification (inputs and recorded results, through `inspection.output_state_status`: a result whose data or region of interest changed is `altered` and is not used as reference): `available`, `changed`, `roi_changed` (same data, other region of interest: restorable for a replay, reported as `changed` by inspection), `altered` or `unsupported`, with a reason. |
| `inspection.Inspector`, `INSPECTION_VERSION`, `MAX_DEPTH`, `MAX_INPUTS`, `MAX_ANALYSES`, `MAX_OBJECT_STATES` | Read-only provenance description of a result, used through the `Recorder` methods below. A bounded JSON projection: the anchored state (`content`: `unchecked` until the data are checked, then `matches`, `differs`, `absent` or `unavailable`; an absent object is reported `absent`, and an absent input or result `missing`, without checking, since nothing has to be read; `file_status` read when the workspace was opened), the producing activity (operation, typed parameters as encoded, ordered inputs with role, presence, producer, `has_ancestors` and `cycle`, outputs, context without metadata values, environment, limits, local `support`: `function` `known`/`unknown`/`incompatible`/`opaque`/`legacy` and `verification` qualification), the latest states of the object, its legacy records and the analyses recorded on it (artifact status, `no_result`). Ancestors are expanded by another call. |
| `compare.compare_rows(candidate, reference, rule=None, *, output="result", environment="unknown")`, `compare_exact(candidate, reference)`, `compare_artifacts(kind, candidate, reference, rule=None, ...)`, `artifact_content(kind, value)`, `compare_environments(...)`, `build_report(..., outputs=None)`, `validate_report(report)` | Comparison under the rule declared by the qualification of the operation contract (`{"rule": "exact"}` by default, or per-output tolerances giving `within_tolerance`, applied exactly within the same environment when declared so); complex values are compared on both parts. Analysis results (stored forms of tables and geometries) are compared on their values under the rule and on their structure (kinds, column names, ROI indices) exactly. Environment comparison and verification reports; with `outputs`, a report compares every output by role (state, analysis result or no result) and its verdict is the most severe one. |
| `recorder.Recorder(host, computation)`, `RecorderHost`, `Computation`, `computation_from_sigima(contracts)`, `CandidateError` | Provenance recorder shared by the editions: `begin`, `complete`, `complete_analysis`, `complete_without_result` (an optional output without result, not an error), `deferred`/`finalize_deferred` (the activities recorded in a deferred context are listed in `deferred.activity_ids`), `command_activity_ids(command_id)`, `artifact_statuses`, `prepare_replay`, `verify(activity_id, execute_candidate)` (every output compared; `execute_candidate` returns what the function returned and raises `CandidateError` on error or cancellation), `reset`, `save`, `read` (`(ledger, state_status, capsule_origin)`), `load(block, replaced, saved_version=None, current_version=None, appended=())` (`capsule_origin`, the capsule the workspace was opened from, is saved with the workspace and restored when a file replaces the workspace, kept when a file is appended; an appended file never merges its ledger, and `recorder.APPEND_NOTICE` is added to `notices` when it held provenance; appended objects holding an identifier already known to the ledger get a new observed state marked `appended_without_provenance` (`inspection.IDENTITY_LIMIT`), so that nothing recorded for another object is attributed to them, and earlier states are not shown as theirs; consulting, checking and verifying apply the same rule, `inspection.holder_of_state` / `holder_of_artifact`: an input, a result or an analysis result of a state before the break is missing). Inspection: `describe_object(object_uuid, check_data=False)`, `describe_state(state_id, check_data=False, trail=())`, `describe_activity(activity_id, check_data=False)` never prepare nor run a computation and never change the ledger; consulting computes no fingerprint, checking the data compares the object and the direct inputs and outputs of the described activity. `producing_call(object_uuid)` returns the call that produced the latest recorded state of an object (every input in order, parameters as encoded, the locally known contract or why it is unavailable), which Processing restores. `resolve_function` returns a contract known to the local library, qualified or not (ordinary processing); `resolve_contract` requires the qualification (verification). The edition provides its accesses (`RecorderHost`) and the computation library its functions (`Computation`); errors of the library are mapped to eligibility codes once, in `computation_from_sigima`, which receives the `sigima.proc.contracts` package. The global options and the digests of the input metadata read by a computation are recorded in the execution context; `begin(..., executed=...)` takes the metadata from the objects actually computed when they differ from the recorded inputs. |
| `recorder.Recorder(host, computation)`, `RecorderHost`, `Computation`, `computation_from_sigima(contracts)`, `CandidateError` | Provenance recorder shared by the editions: `begin`, `complete`, `complete_analysis`, `complete_without_result` (an optional output without result, not an error), `deferred`/`finalize_deferred` (the activities recorded in a deferred context are listed in `deferred.activity_ids`), `command_activity_ids(command_id)`, `artifact_statuses`, `prepare_replay`, `verify(activity_id, execute_candidate)` (every output compared; `execute_candidate` returns what the function returned and raises `CandidateError` on error or cancellation), `reset`, `save`, `read` (`(ledger, state_status, capsule_origin)`), `load(block, replaced, saved_version=None, current_version=None, appended=())` (`capsule_origin`, the capsule the workspace was opened from, is saved with the workspace and restored when a file replaces the workspace, kept when a file is appended; an appended file never merges its ledger, and `recorder.APPEND_NOTICE` is added to `notices` when it held provenance; appended objects holding an identifier already known to the ledger get a new observed state marked `appended_without_provenance` (`inspection.IDENTITY_LIMIT`), so that nothing recorded for another object is attributed to them, and earlier states are not shown as theirs; consulting, checking and verifying apply the same rule, `inspection.holder_of_state` / `holder_of_artifact`: an input, a result or an analysis result of a state before the break is missing). Inspection: `describe_object(object_uuid, check_data=False)`, `describe_state(state_id, check_data=False, trail=())`, `describe_activity(activity_id, check_data=False)` never prepare nor run a computation and never change the ledger; consulting computes no fingerprint, checking the data compares the object and the direct inputs and outputs of the described activity. `producing_call(object_uuid)` returns the call that produced the latest recorded state of an object (every input in order, parameters as encoded, the locally known contract or why it is unavailable), which Processing restores. `resolve_function` returns a contract known to the local library, qualified or not (ordinary processing); `resolve_contract` requires the qualification (verification). The edition provides its accesses (`RecorderHost`) and the computation library its functions (`Computation`); errors of the library are mapped to eligibility codes once, in `computation_from_sigima`, which receives the `sigima.proc.contracts` package. The global options and the digests of the input metadata read by a computation, and the entries of the `context` mapping of its description (rules declared by the library, which checks them when replaying), are recorded in the execution context; `begin(..., executed=...)` takes the metadata from the objects actually computed when they differ from the recorded inputs. |

## Canonical JSON and limits

Expand Down
2 changes: 1 addition & 1 deletion doc/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ A state is **located** in a workspace when the saved object with the same UUID h
- **Fingerprint schemes.** A scheme name is never reused with another payload: a change of payload gets a new scheme name. This version computes `datalab-signal-v1`, `datalab-signal-v2` and `datalab-image-v1`; a signal recorded with `datalab-signal-v1` keeps that scheme.
- **Operations.** A call names an operation contract (`id`, `contract_version`) as soon as the computation library knows the function, whether or not the contract is qualified for replay. Replaying needs the same identifier and contract version, and a local contract qualified for that version; otherwise the activity is refused with `unsupported_operation` (unknown operation) or `unsupported_contract` (version unknown or not qualified, with the reason). The implementation (package, version, Python name) is informative only and never used to find a function.
- **Parameters.** Parameters are recorded as plain JSON by the codec of the computation library: scalar values, choices by their stable key, typed forms for values without a lossless JSON form (`{"$float": "NaN"}`, `$complex`, `$tuple`, `$dict`, `$date`, `$datetime`, `$ndarray`), lists of parameter sets (e.g. several ROIs) and, for a call with several parameter arguments, one entry per argument. They are decoded against the parameter classes of the local contract; no class is built from a name read in a document.
- **Execution context.** The context of an activity records what the computation depends on besides inputs and parameters: the X-alignment policy applied (identifier, version and outcome), the global options read by the computation (e.g. `auto_normalize_kernel`) and the digests of the metadata of inputs it reads (e.g. the fit parameters read by `evaluate_fit`; state fingerprints do not cover metadata). A replay applies the recorded context; an unknown policy, a missing option or unrecorded metadata is refused (`unsupported_context`), and metadata read that changed since the activity give `input_changed`.
- **Execution context.** The context of an activity records what the computation depends on besides inputs and parameters: the X-alignment policy applied (identifier, version and outcome), the global options read by the computation (e.g. `auto_normalize_kernel`), the digests of the metadata of inputs it reads (e.g. the fit parameters read by `evaluate_fit`; state fingerprints do not cover metadata) and the other rules declared by the computation library in the description of the call (e.g. the convention selecting image pixels in regions of interest), recorded as plain data. A replay applies the recorded context; an unknown policy, a missing option, unrecorded metadata or a missing or unknown rule is refused (`unsupported_context`), and metadata read that changed since the activity give `input_changed`.
- **Environments.** Differences between the recorded and the current environment are reported (`same`, `different`, `unknown`) and never block a replay.
- **DataLab versions.** A workspace saved by a DataLab without provenance is imported as `legacy_metadata` activities (see the README); a version mismatch or an unknown version is listed among the import issues, never hidden.
- **Unknown properties.** Manifest consumers may ignore RO-Crate properties they do not know; the library itself validates the manifest structure it needs and refuses inconsistent manifests (fingerprint, workspace entity, references).
Expand Down
13 changes: 10 additions & 3 deletions src/datalab_capsule/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from __future__ import annotations

import contextlib
import copy
import dataclasses
import logging
import uuid
Expand Down Expand Up @@ -134,7 +135,9 @@ class Computation:

Attributes:
describe_call: ``(func, inputs, param, *, roles=None, contract=...) ->
description`` (see ``sigima.proc.contracts.describe_call``).
description`` (see ``sigima.proc.contracts.describe_call``); entries of
its optional ``context`` mapping (plain JSON data) are recorded in the
execution context of the activity.
get_contract: ``(operation_id, contract_version) -> contract``; raises
when the operation or the version is unknown.
prepare_execution: ``(contract, inputs, *, values, context) -> prepared``
Expand Down Expand Up @@ -511,7 +514,9 @@ def _build_call(
The identity of the operation is kept as soon as the computation library
knows it, whether or not its contract is qualified for replay: the
qualification is checked when replaying, against the local library. The
context holds the global options read by the computation, if any, and
context holds the entries declared by the library in the ``context`` of
its description (rules the computation depends on, checked by the library
when replaying), the global options read by the computation, if any, and
the digests of the metadata of inputs it reads (see
:meth:`_metadata_read`), taken from *executed* when given.
"""
Expand All @@ -523,8 +528,10 @@ def _build_call(
description["parameters"],
list(zip(description["roles"], state_ids)),
)
context: dict[str, Any] = copy.deepcopy(description.get("context") or {})
options = description.get("options")
context: dict[str, Any] = {"options": dict(options)} if options else {}
if options:
context["options"] = dict(options)
reads = description.get("metadata_read") or []
if reads:
sources = objs if executed is None else list(executed)
Expand Down
30 changes: 30 additions & 0 deletions tests/test_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,20 @@ def detect(src, threshold):
return FakeTable(found) if threshold >= 0 else None


def ruled(src, factor):
"""Fake operation depending on a rule declared by the computation library."""
return signal(src.y * factor, x=src.x)


RULE = {"rule": "test.rule", "version": 1}


CONTRACTS = {
("test.scale", 1): FakeContract(scale),
("test.shift", 1): FakeContract(shift, qualified=False),
("test.detect", 1): FakeContract(detect),
("test.gain", 1): FakeContract(gain),
("test.ruled", 1): FakeContract(ruled),
}


Expand All @@ -122,8 +131,11 @@ def describe_call(func, inputs, param, *, roles=None, contract=...):
shift: "test.shift",
detect: "test.detect",
gain: "test.gain",
ruled: "test.ruled",
}
identified = contract is ... and func in known
# Only some library versions declare a context
declared = {"context": {"convention": dict(RULE)}} if func is ruled else {}
return {
"operation": {"id": known[func], "contract_version": 1} if identified else None,
"qualification": {
Expand All @@ -144,6 +156,7 @@ def describe_call(func, inputs, param, *, roles=None, contract=...):
),
"limits": ["parameters_not_encoded"] if param == "unencodable" else [],
"implementation": {"package": "test", "version": "1", "python_name": "x"},
**declared,
}


Expand Down Expand Up @@ -641,3 +654,20 @@ def test_metadata_read_by_the_computation(workspace) -> None:
used.metadata = {"gain": 7.0}
pending = recorder.begin(gain, 3.0, src, executed=[used])
assert pending.context["metadata"][0]["digest"] != read["digest"]


def test_context_declared_by_the_library(workspace) -> None:
"""Rules declared by the library are recorded and passed back for a replay."""
recorder = Recorder(workspace.host(), computation())
src = workspace.add(signal([1.0, 2.0, 3.0, 4.0]), 0)
pending = recorder.begin(ruled, 2.0, src)
activity = recorder.complete(pending, workspace.add(ruled(src, 2.0), 1))
assert activity["context"]["convention"] == RULE
assert activity["context"]["x_alignment"] is None
activity_id = activity["activity_id"]
_plan, prepared = recorder.prepare_replay(activity_id)
assert prepared.context["convention"] == RULE
assert recorder.verify(activity_id, lambda p: p.run())["verdict"] == "exact"
# Descriptions without context (earlier library versions) record none
_pending, activity = _run(recorder, workspace, scale, 2.0, index=2)
assert "convention" not in activity["context"]
Loading