From a295b864a952fbd5baf2d10f584865ac1a1547ac Mon Sep 17 00:00:00 2001 From: epi13 Date: Sat, 8 Aug 2026 17:24:21 -0800 Subject: [PATCH] Advance RAVEL 0.6 development foundations --- README.md | 8 +- docs/ARCHITECTURE.md | 22 ++ docs/PROJECT_MAP.md | 8 + docs/PROVIDER_RUNTIME.md | 34 +++ docs/SEMANTIC_CONSOLIDATION.md | 20 +- ravel_versions/0.6/Makefile | 11 + .../0.6/RAVEL_0_6_IMPLEMENTATION_STATUS.md | 36 +++ ravel_versions/0.6/RAVEL_0_6_NEXT_STEPS.md | 11 + .../0.6/ravel-0.6-development-record.json | 5 +- schemas/consolidation-proposal.schema.json | 2 + src/ravel/adaptation.py | 237 ++++++++++++++++++ src/ravel/memory/__init__.py | 4 + src/ravel/memory/consolidation.py | 19 +- src/ravel/memory/models.py | 43 ++++ src/ravel/memory/store.py | 212 ++++++++++++++-- src/ravel/providers.py | 127 ++++++++++ src/ravel/resources.py | 194 ++++++++++++++ tests/test_adaptation.py | 126 ++++++++++ tests/test_consolidation.py | 95 ++++++- tests/test_providers_resources.py | 112 +++++++++ tests/test_ravel_0_6_provenance.py | 49 ++++ tools/README.md | 13 + tools/ravel_0_6_behavioral_fixtures.c | 85 +++++++ tools/ravel_0_6_behavioral_fixtures.py | 102 ++++++++ tools/ravel_0_6_build.py | 201 +++++++++++++++ 25 files changed, 1735 insertions(+), 41 deletions(-) create mode 100644 docs/PROVIDER_RUNTIME.md create mode 100644 ravel_versions/0.6/Makefile create mode 100644 ravel_versions/0.6/RAVEL_0_6_IMPLEMENTATION_STATUS.md create mode 100644 src/ravel/adaptation.py create mode 100644 src/ravel/providers.py create mode 100644 src/ravel/resources.py create mode 100644 tests/test_adaptation.py create mode 100644 tests/test_providers_resources.py create mode 100644 tests/test_ravel_0_6_provenance.py create mode 100644 tools/ravel_0_6_behavioral_fixtures.c create mode 100644 tools/ravel_0_6_behavioral_fixtures.py create mode 100644 tools/ravel_0_6_build.py diff --git a/README.md b/README.md index 73467d3..b655144 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ RAVEL — the **Recursive Adaptive Vector Execution Lattice** — is an experime RAVEL operates beneath the technical authority of the Machine-Native Complexity Standard (MNCS) and the Machine-Native Complexity Development Standard (MNCDS). It is not intended to replace a language model, compiler, static analyzer, test framework, or the MNCS Forge. Its role is to decide what evidence should be gathered, what action should follow, and what experience should be retained for later use without redefining the governing status of that evidence. -> **Project status:** RAVEL is research software. Historical RAVEL 0.4 and 0.5 results remain development `FAIL`; RAVEL 0.6 is preregistered but not finally evaluated or promoted. Formal MNCS/MNCDS conformance, independent attestation, protected custody, production safety, and general recursive self-improvement remain `UNKNOWN`. +> **Project status:** RAVEL is research software. Historical RAVEL 0.4 and 0.5 results remain development `FAIL`; RAVEL 0.6 candidate-001 now has a reproducible development build and behavioral harness, but has not been evaluated, frozen, selected, or promoted. Formal MNCS/MNCDS conformance, independent attestation, protected custody, production safety, and general recursive self-improvement remain `UNKNOWN`. ## Place in the MNCS ecosystem @@ -102,6 +102,12 @@ The recommended extraction is history-preserving rather than a manual file copy. 5. Update MNCS to reference RAVEL as a sibling project without deleting historical evidence prematurely. 6. Develop the Forge interface and MNCS/MNCDS-governed evidence and knowledge schemas before adding broad autonomy. +The current 0.6 implementation status is recorded in +[`ravel_versions/0.6/RAVEL_0_6_IMPLEMENTATION_STATUS.md`](ravel_versions/0.6/RAVEL_0_6_IMPLEMENTATION_STATUS.md). +Optional provider/resource protocols in `src/ravel/providers.py` and +`src/ravel/resources.py` record scoped execution observations without claiming +algorithmic superiority. + ## Non-goals RAVEL is not currently claiming: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 44da894..7ba1508 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -125,6 +125,28 @@ A bounded RAVEL episode should follow this sequence: 11. **Extract provisional knowledge.** Create principles or strategies only when attribution and scope support them. 12. **Test transfer.** Reuse remains restricted until separate contexts support the declared scope. +## Implemented reusable foundations + +The repository now contains small, dependency-free foundations beneath the +0.6 experiment: + +- `ravel.adaptation` performs copy-before-evaluate retention transactions and + emits stable rejection reason codes without treating raw observations as + authority. +- `ravel.providers` defines a narrow Forge boundary with capability discovery, + immutable raw evidence, and fail-closed `UNKNOWN` receipts. It is an adapter, + not a Forge reimplementation. +- `ravel.resources` separates future provider placement policy from the + preregistered 0.6 mechanism. Fake backends exercise CPU fallback, CUDA kernel + probes, VRAM headroom, sequential offload, and bounded OOM recovery. +- `ravel.memory` retains versioned source identities, explicit scope contracts, + negative-aware full-text retrieval, append-only proposal lifecycle events, + atomic batches, and rebuildable relation projections. + +These modules are tested scaffolding and are not claims that RAVEL 0.6 has been +evaluated, selected, independently evaluated, certified, promoted, or made +production-safe. + ## Separation of statuses RAVEL must not collapse different state spaces into one score. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 0805dc8..c0dd9c4 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -15,6 +15,11 @@ remain unchanged and are resolved by the versioned compatibility runners. | 0.5 | `ravel_versions/0.5/ravel_0_5.c` | `ravel_versions/0.5/RAVEL_0_5_CONTRACT.md` | | 0.6 | Candidate source is derived by `tools/ravel_0_6_seed_candidate.py` | `ravel_versions/0.6/RAVEL_0_6_SCOPE.md` and preregistration | +Candidate-001 development builds are orchestrated by +`tools/ravel_0_6_build.py`; generated source and binaries are temporary +development material, not maintained source or final evidence. Behavioral +facts are produced by `tools/ravel_0_6_behavioral_fixtures.py`. + Build binaries are local outputs and are removed by `make clean`. ## Protocol, evidence, and identity @@ -45,6 +50,9 @@ regenerating the frozen records. - [Architecture gaps](ARCHITECTURE_GAPS.md) records the early design gaps. - [`../tools/README.md`](../tools/README.md) documents evaluators, digest tools, mutation checks, runtime capture, and 0.6 candidate derivation. +- `src/ravel/adaptation.py` provides the tested retention-transaction boundary. +- `src/ravel/providers.py` and `src/ravel/resources.py` provide optional, + replaceable Forge/runtime protocols without heavy ML dependencies. - [`MIGRATION.md`](MIGRATION.md) records the standalone extraction provenance. ## Build and verification entry points diff --git a/docs/PROVIDER_RUNTIME.md b/docs/PROVIDER_RUNTIME.md new file mode 100644 index 0000000..9713aa5 --- /dev/null +++ b/docs/PROVIDER_RUNTIME.md @@ -0,0 +1,34 @@ +# Provider and resource runtime boundary + +RAVEL can request evidence from a Forge-compatible provider and retain a +scoped execution experience, but Forge remains the evidence executor and MNCS / +MNCDS remains authoritative. `src/ravel/providers.py` is intentionally small: +it exposes capability discovery, a typed evidence request, immutable raw +evidence, and a separate fail-closed receipt. Missing capabilities, provider +crashes, malformed statuses, and identity mismatches become `UNKNOWN`. + +`src/ravel/resources.py` is an optional policy layer for future learned +providers. It has no PyTorch, CUDA, Transformers, or model-weight dependency. +It records observations such as: + +- `auto`, `cpu`, or `cuda` device policy; +- no offload or sequential CPU offload; +- actual CUDA-kernel and dtype probes supplied by the backend; +- free VRAM, reserve/headroom, maximum VRAM budget, CPU availability, and RSS; +- model parameter, workspace, and peak-module estimates; +- placement reason, peak VRAM reservation, parameter residency, and timing or + failure observations supplied by the backend. + +Sequential CPU offload keeps primary parameters in system RAM between module +executions. It trades VRAM for system RAM; it does not reduce total memory +requirements. A CUDA out-of-memory signal permits at most one recovery decision, +after which the backend either executes the selected fallback or fails closed. + +These measurements are scoped empirical experience, not evidence of +algorithmic superiority. A future experience record should bind provider, +model, hardware, task class, device, placement, dtype, budget, runtime, failure +status, evidence quality, and verifier outcome before any strategy reuse. + +Standalone RAVEL tests use fake providers and resource backends. Forge is not a +mandatory dependency for the core package, and lack of a GPU does not make the +normal test suite fail. diff --git a/docs/SEMANTIC_CONSOLIDATION.md b/docs/SEMANTIC_CONSOLIDATION.md index 6a4c510..30032a8 100644 --- a/docs/SEMANTIC_CONSOLIDATION.md +++ b/docs/SEMANTIC_CONSOLIDATION.md @@ -2,7 +2,12 @@ ## Status -This document specifies a development prototype. It does not establish MNCS or MNCDS conformance, protected custody, production safety, or validated recursive self-improvement. +This document specifies a development prototype. The prototype now has named +scope-compatibility contracts, versioned evidence/experience identity fields, +deterministic source full-text retrieval, append-only proposal lifecycle events, +atomic source batches, and rebuildable relation projections. It does not +establish MNCS or MNCDS conformance, protected custody, production safety, or +validated recursive self-improvement. ## Motivation @@ -58,7 +63,9 @@ The source store remains authoritative. Consolidation proposals and layout plans ### 1. Candidate partitioning -Records are first partitioned by memory class and exact declared scope. The prototype does not infer that two scopes are compatible. Future scope adapters may implement version-aware compatibility rules, but those rules must be explicit and testable. +Records are first partitioned by memory class and a named scope-compatibility +contract. The default contract remains exact scope equality; an alternate +contract may be used only when its compatibility rule is explicit and tested. ### 2. Similarity grouping @@ -159,11 +166,8 @@ A faster result is not sufficient if it hides negative evidence, increases false ## Next steps -1. Bind records to the versioned RAVEL evidence and experience schemas. -2. Add explicit scope-compatibility contracts instead of exact scope equality. -3. Add full-text retrieval and a benchmark corpus before embeddings. -4. Record query events in the experience store with privacy and retention controls. -5. Implement proposal review, acceptance, challenge, and supersession records. -6. Add graph projection rebuilds from the append-only source stream. +1. Bind records to the complete versioned RAVEL evidence and experience schemas. +2. Record query events in the experience store with privacy and retention controls. +3. Add benchmark corpus measurements for retrieval quality, negative recall, and cost. 7. Benchmark physical page, cache, and shard layouts on the planned local and distributed RAVEL environments. 8. Add embedding-assisted candidate generation only after the deterministic baseline is measured. diff --git a/ravel_versions/0.6/Makefile b/ravel_versions/0.6/Makefile new file mode 100644 index 0000000..d9e1470 --- /dev/null +++ b/ravel_versions/0.6/Makefile @@ -0,0 +1,11 @@ +CC ?= cc +CFLAGS ?= -std=c11 -O3 -Wall -Wextra -Werror -pedantic + +.PHONY: build behavioral-test + +build: + @set -e; out=$$(mktemp -d); python3 tools/ravel_0_6_build.py build --output-dir "$$out"; \ + printf 'RAVEL 0.6 development build directory: %s\n' "$$out" + +behavioral-test: + python3 tools/ravel_0_6_behavioral_fixtures.py diff --git a/ravel_versions/0.6/RAVEL_0_6_IMPLEMENTATION_STATUS.md b/ravel_versions/0.6/RAVEL_0_6_IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..f3b5fa4 --- /dev/null +++ b/ravel_versions/0.6/RAVEL_0_6_IMPLEMENTATION_STATUS.md @@ -0,0 +1,36 @@ +# RAVEL 0.6 implementation status + +This is a development status record, not RAVEL 0.6 evaluation evidence. + +## Implemented and tested + +- **R6-01 foundation:** candidate `ravel-0.6-candidate-001` is generated from + the exact frozen 0.5 source identity. The temporary build record binds the + frozen source, generator, generated source, compiler/version/argv, selected + environment-key identities, worktree status, stdout, stderr, and exit + status. Generated source is explicitly development-only. +- **R6-02 transaction foundation:** the Python adaptation surface evaluates + independent hard constraints over a copied checkpoint and restores the + preceding bytes on any rejection. Rejection codes cover objective epsilon, + retention floors, prediction degradation, transition support, topology, + replay, update, and compute budgets. This surface is tested with one + negative fixture per constraint; the generated C candidate has not yet + consumed this generic interface as a final evaluator. +- **R6-03 behavioral fixtures:** a compiled harness reaches a goal only through + transition slot one and checks that a born child has only spawning-event + support. The candidate passes; frozen 0.5 and each reverted correction fail. + Outputs are integer facts plus a stable checksum. + +## Not yet implemented + +- R6-02 is not a completed 0.6 trial/evaluator pipeline. No development, + selection, or final material evaluation has been executed. +- R6-04 has not been claimed: the large generated C mechanism has not yet been + split into independent mechanism, provider, planning, checkpoint, and + observation units. +- R6-05 candidate ledgers and selection partition consumption are not present. +- R6-06 external final custody/evaluation remains unavailable and `UNKNOWN`. + +The 0.6 candidate remains unfrozen, unselected, unpromoted, and unauthorized +to alter evaluator identity, thresholds, partitions, evidence custody, or +formal MNCS/MNCDS status. diff --git a/ravel_versions/0.6/RAVEL_0_6_NEXT_STEPS.md b/ravel_versions/0.6/RAVEL_0_6_NEXT_STEPS.md index 5d0d6b9..fc6265e 100644 --- a/ravel_versions/0.6/RAVEL_0_6_NEXT_STEPS.md +++ b/ravel_versions/0.6/RAVEL_0_6_NEXT_STEPS.md @@ -43,6 +43,17 @@ checking, and strict C11 compilation. These corrections produce development source only. Candidate 001 has not been integrated into the 0.6 evidence pipeline, selected, frozen, or evaluated. +## Current bounded implementation status + +Candidate-001 is now integrated as a reproducible temporary development build +by `tools/ravel_0_6_build.py`. A compiled behavioral harness verifies both +corrections and fails against frozen 0.5 behavior and bounded reverted +mutations. A separate Python transaction foundation tests all declared hard +constraint classes with byte-identical rollback. These are implementation and +mutation observations only; the C candidate is not yet a complete 0.6 trial +evaluator, and no selection or final material has been consumed. See +`RAVEL_0_6_IMPLEMENTATION_STATUS.md` for the exact boundary. + ## Codex next steps Perform the following tasks in order. Treat each material post-evaluation change diff --git a/ravel_versions/0.6/ravel-0.6-development-record.json b/ravel_versions/0.6/ravel-0.6-development-record.json index 5163120..d4fb2b6 100644 --- a/ravel_versions/0.6/ravel-0.6-development-record.json +++ b/ravel_versions/0.6/ravel-0.6-development-record.json @@ -6,7 +6,10 @@ "recorded_at": "2026-08-01T00:00:00Z", "record_class": "operator_controlled_preregistration_state", "candidate": { - "implementation_status": "NOT_IMPLEMENTED", + "implementation_status": "DEVELOPMENT_CANDIDATE_INTEGRATED", + "candidate_id": "ravel-0.6-candidate-001", + "generated_source_sha256": "b71faca6aff74be89139f8d0c9d9eadfc1485c1e81d12b6ccd945f98e8a81c9a", + "generator": "tools/ravel_0_6_build.py", "selected_candidate_id": null, "frozen": false, "manifest_identity": null diff --git a/schemas/consolidation-proposal.schema.json b/schemas/consolidation-proposal.schema.json index 6df6665..98c65a6 100644 --- a/schemas/consolidation-proposal.schema.json +++ b/schemas/consolidation-proposal.schema.json @@ -18,6 +18,7 @@ "retrieval_keys", "clustering_confidence", "status", + "scope_contract_id", "limitations" ], "properties": { @@ -68,6 +69,7 @@ "maximum": 1 }, "status": {"const": "proposed"}, + "scope_contract_id": {"type": "string", "minLength": 1}, "limitations": { "type": "array", "minItems": 1, diff --git a/src/ravel/adaptation.py b/src/ravel/adaptation.py new file mode 100644 index 0000000..b5077af --- /dev/null +++ b/src/ravel/adaptation.py @@ -0,0 +1,237 @@ +"""Retention-constrained adaptation transactions. + +This module is a provider-neutral development surface. It evaluates raw +observations against an immutable policy and commits a proposed checkpoint only +when every hard constraint passes. It does not derive MNCS/MNCDS authority or a +final RAVEL 0.6 disposition. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import json +import math +from typing import Callable + + +class AdaptationInputError(ValueError): + """Raised when an observation or policy is not a valid hard-gate input.""" + + +def _finite(value: float, name: str) -> float: + if not math.isfinite(value): + raise AdaptationInputError(f"{name} must be finite") + return value + + +def _nonnegative(value: int, name: str) -> int: + if value < 0: + raise AdaptationInputError(f"{name} must be non-negative") + return value + + +@dataclass(frozen=True, slots=True) +class RawObservation: + """Executable measurements retained separately from gate dispositions.""" + + adaptation_objective: float + base_accuracy: float + representation_score: float + original_prediction_degradation: float + transition_support_losses: int + expert_count: int + births: int + retirements: int + replay_records: int + update_passes: int + compute_evaluations: int + matched_compute_evaluations: int + + def __post_init__(self) -> None: + for name in ( + "adaptation_objective", + "base_accuracy", + "representation_score", + "original_prediction_degradation", + ): + _finite(getattr(self, name), name) + for name in ( + "transition_support_losses", + "expert_count", + "births", + "retirements", + "replay_records", + "update_passes", + "compute_evaluations", + "matched_compute_evaluations", + ): + _nonnegative(getattr(self, name), name) + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class RetentionConstraintPolicy: + """Explicit hard constraints for one development transaction.""" + + adaptation_improvement_epsilon: float + base_accuracy_floor: float + representation_floor: float + original_prediction_degradation_bound: float + maximum_transition_support_losses: int + maximum_experts: int + maximum_births: int + maximum_retirements: int + maximum_replay_records: int + maximum_update_passes: int + maximum_compute_evaluations: int + maximum_compute_ratio: float + + def __post_init__(self) -> None: + for name in ( + "adaptation_improvement_epsilon", + "base_accuracy_floor", + "representation_floor", + "original_prediction_degradation_bound", + "maximum_compute_ratio", + ): + value = _finite(getattr(self, name), name) + if value < 0: + raise AdaptationInputError(f"{name} must be non-negative") + for name in ( + "maximum_transition_support_losses", + "maximum_experts", + "maximum_births", + "maximum_retirements", + "maximum_replay_records", + "maximum_update_passes", + "maximum_compute_evaluations", + ): + _nonnegative(getattr(self, name), name) + + +@dataclass(frozen=True, slots=True) +class ConstraintReport: + """Independent hard-gate results in stable order.""" + + passed: bool + rejection_reasons: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + "passed": self.passed, + "rejection_reasons": list(self.rejection_reasons), + } + + +def evaluate_constraints( + previous: RawObservation, + proposed: RawObservation, + policy: RetentionConstraintPolicy, +) -> ConstraintReport: + """Derive each hard gate independently; no metric compensates for another.""" + + reasons: list[str] = [] + if proposed.adaptation_objective - previous.adaptation_objective < policy.adaptation_improvement_epsilon: + reasons.append("adaptation_improvement_below_epsilon") + if proposed.base_accuracy < policy.base_accuracy_floor: + reasons.append("base_accuracy_floor") + if proposed.representation_score < policy.representation_floor: + reasons.append("representation_floor") + if proposed.original_prediction_degradation > policy.original_prediction_degradation_bound: + reasons.append("original_prediction_degradation_bound") + if proposed.transition_support_losses > policy.maximum_transition_support_losses: + reasons.append("transition_support_preservation") + if proposed.expert_count > policy.maximum_experts: + reasons.append("expert_capacity_budget") + if proposed.births > policy.maximum_births: + reasons.append("birth_budget") + if proposed.retirements > policy.maximum_retirements: + reasons.append("retirement_budget") + if proposed.replay_records > policy.maximum_replay_records: + reasons.append("replay_budget") + if proposed.update_passes > policy.maximum_update_passes: + reasons.append("update_pass_budget") + if proposed.compute_evaluations > policy.maximum_compute_evaluations: + reasons.append("compute_budget") + if proposed.matched_compute_evaluations == 0: + reasons.append("matched_compute_reference_unavailable") + elif ( + proposed.compute_evaluations / proposed.matched_compute_evaluations + > policy.maximum_compute_ratio + ): + reasons.append("matched_compute_ratio") + return ConstraintReport(not reasons, tuple(reasons)) + + +@dataclass(frozen=True, slots=True) +class AdaptationTransaction: + """Raw observation plus the exact committed or rolled-back bytes.""" + + status: str + state_before: bytes + state_after: bytes + observation: RawObservation + report: ConstraintReport + checkpoint_before_sha256: str + checkpoint_after_sha256: str + + def __post_init__(self) -> None: + if self.status not in {"accepted", "rejected"}: + raise AdaptationInputError(f"unknown transaction status: {self.status}") + if self.status == "accepted" and not self.report.passed: + raise AdaptationInputError("accepted transaction has failed constraints") + if self.status == "rejected" and self.report.passed: + raise AdaptationInputError("rejected transaction has no failed constraints") + + @property + def rolled_back_byte_identical(self) -> bool: + return self.status == "rejected" and self.state_before == self.state_after + + def to_dict(self) -> dict[str, object]: + return { + "status": self.status, + "state_before_sha256": self.checkpoint_before_sha256, + "state_after_sha256": self.checkpoint_after_sha256, + "observation": self.observation.to_dict(), + "constraints": self.report.to_dict(), + "rolled_back_byte_identical": self.rolled_back_byte_identical, + } + + +def _digest(state: bytes) -> str: + return hashlib.sha256(state).hexdigest() + + +def run_transaction( + previous_state: bytes, + previous_observation: RawObservation, + propose: Callable[[bytes], bytes], + observe: Callable[[bytes], RawObservation], + policy: RetentionConstraintPolicy, +) -> AdaptationTransaction: + """Evaluate a copied proposal and commit only when all gates pass.""" + + before = bytes(previous_state) + candidate = bytes(propose(before)) + observation = observe(candidate) + report = evaluate_constraints(previous_observation, observation, policy) + after = candidate if report.passed else before + status = "accepted" if report.passed else "rejected" + return AdaptationTransaction( + status=status, + state_before=before, + state_after=after, + observation=observation, + report=report, + checkpoint_before_sha256=_digest(before), + checkpoint_after_sha256=_digest(after), + ) + + +def canonical_transaction_json(transaction: AdaptationTransaction) -> str: + """Serialize a disposition deterministically for append-only records.""" + + return json.dumps(transaction.to_dict(), sort_keys=True, separators=(",", ":")) diff --git a/src/ravel/memory/__init__.py b/src/ravel/memory/__init__.py index 9760d8e..b7a4acb 100644 --- a/src/ravel/memory/__init__.py +++ b/src/ravel/memory/__init__.py @@ -10,7 +10,9 @@ ConsolidationProposal, MemoryClass, MemoryRecord, + ProposalLifecycleEvent, RetrievalBucket, + ScopeCompatibility, ) from .store import ImmutableRecordError, SQLiteMemoryStore @@ -22,7 +24,9 @@ "MemoryClass", "MemoryConsolidator", "MemoryRecord", + "ProposalLifecycleEvent", "RetrievalBucket", "RetrievalLayoutPlanner", + "ScopeCompatibility", "SQLiteMemoryStore", ] diff --git a/src/ravel/memory/consolidation.py b/src/ravel/memory/consolidation.py index eea58a6..f1054d2 100644 --- a/src/ravel/memory/consolidation.py +++ b/src/ravel/memory/consolidation.py @@ -18,6 +18,7 @@ ConsolidationProposal, MemoryRecord, RetrievalBucket, + ScopeCompatibility, utc_now, ) @@ -106,6 +107,7 @@ class ConsolidationPolicy: maximum_cluster_size: int = 64 retrieval_key_count: int = 8 method_version: str = "ravel-semantic-consolidation/0.1" + scope_compatibility: ScopeCompatibility = ScopeCompatibility() def __post_init__(self) -> None: if not 0.0 <= self.similarity_threshold <= 1.0: @@ -138,13 +140,23 @@ def propose( if len(record_by_id) != len(ordered): raise ValueError("record_id values must be unique") - groups: dict[tuple[object, ...], list[MemoryRecord]] = defaultdict(list) + groups: list[list[MemoryRecord]] = [] for record in ordered: - groups[(record.memory_class, record.scope_signature)].append(record) + for group in groups: + if ( + group[0].memory_class == record.memory_class + and self.policy.scope_compatibility.compatible( + group[0].scope, record.scope + ) + ): + group.append(record) + break + else: + groups.append([record]) proposals: list[ConsolidationProposal] = [] timestamp = created_at or utc_now() - for scoped_records in groups.values(): + for scoped_records in groups: proposals.extend( self._propose_group(scoped_records, record_by_id, created_at=timestamp) ) @@ -232,6 +244,7 @@ def _make_proposal( superseded_ids=superseded_ids, retrieval_keys=keys, clustering_confidence=round(confidence, 6), + scope_contract_id=self.policy.scope_compatibility.contract_id, ) @staticmethod diff --git a/src/ravel/memory/models.py b/src/ravel/memory/models.py index 93bb37f..1e97431 100644 --- a/src/ravel/memory/models.py +++ b/src/ravel/memory/models.py @@ -42,6 +42,22 @@ class MemoryClass(str, Enum): NEGATIVE = "negative" +@dataclass(frozen=True, slots=True) +class ScopeCompatibility: + """Named, testable policy for deciding whether two scopes may be grouped.""" + + contract_id: str = "ravel-scope-exact/1" + equal_fields: tuple[str, ...] = () + allow_extra_fields: bool = False + + def compatible(self, left: Mapping[str, str], right: Mapping[str, str]) -> bool: + if self.equal_fields: + if any(left.get(field) != right.get(field) for field in self.equal_fields): + return False + return self.allow_extra_fields or set(left) == set(right) + return dict(left) == dict(right) + + @dataclass(frozen=True, slots=True) class MemoryRecord: """An immutable source memory record. @@ -64,6 +80,9 @@ class MemoryRecord: source_ids: tuple[str, ...] = () relations: Mapping[str, tuple[str, ...]] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict) + schema_version: str = "ravel-memory-record/0.1" + evidence_identity: str | None = None + experience_identity: str | None = None def __post_init__(self) -> None: if not self.record_id.strip(): @@ -74,6 +93,8 @@ def __post_init__(self) -> None: raise ValueError("scope must declare at least one boundary") if not self.producer_id.strip(): raise ValueError("producer_id must not be empty") + if not self.schema_version.strip(): + raise ValueError("schema_version must not be empty") def to_dict(self) -> dict[str, Any]: payload = asdict(self) @@ -113,6 +134,7 @@ class ConsolidationProposal: retrieval_keys: tuple[str, ...] clustering_confidence: float status: str = "proposed" + scope_contract_id: str = "ravel-scope-exact/1" limitations: tuple[str, ...] = ( "Derived projection only; does not alter source status or authority.", ) @@ -154,3 +176,24 @@ class RetrievalBucket: member_ids: tuple[str, ...] weighted_edges: tuple[tuple[str, str, int], ...] reason: str = "frequent-co-access" + + +@dataclass(frozen=True, slots=True) +class ProposalLifecycleEvent: + """Append-only review state transition for a consolidation proposal.""" + + event_id: str + proposal_id: str + status: str + created_at: str + reason: str + + ALLOWED_STATUSES = frozenset( + {"proposed", "reviewed", "accepted", "challenged", "superseded"} + ) + + def __post_init__(self) -> None: + if self.status not in self.ALLOWED_STATUSES: + raise ValueError(f"unsupported proposal lifecycle status: {self.status}") + if not self.event_id or not self.proposal_id or not self.reason: + raise ValueError("proposal lifecycle identity and reason are required") diff --git a/src/ravel/memory/store.py b/src/ravel/memory/store.py index fdf720f..766dd52 100644 --- a/src/ravel/memory/store.py +++ b/src/ravel/memory/store.py @@ -3,11 +3,19 @@ from __future__ import annotations import json +import hashlib from pathlib import Path +import re import sqlite3 from typing import Iterable -from .models import ConsolidationProposal, MemoryClass, MemoryRecord, canonical_json +from .models import ( + ConsolidationProposal, + MemoryClass, + MemoryRecord, + ProposalLifecycleEvent, + canonical_json, +) class ImmutableRecordError(RuntimeError): @@ -66,6 +74,19 @@ def _create_schema(self) -> None: REFERENCES source_records(record_id) ); + CREATE TABLE IF NOT EXISTS proposal_lifecycle_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + proposal_id TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + digest TEXT NOT NULL, + payload_json TEXT NOT NULL, + FOREIGN KEY (proposal_id) + REFERENCES consolidation_proposals(proposal_id) + ); + CREATE INDEX IF NOT EXISTS source_records_class_idx ON source_records(memory_class, sequence); CREATE INDEX IF NOT EXISTS proposal_status_idx @@ -74,35 +95,44 @@ def _create_schema(self) -> None: ) def insert_record(self, record: MemoryRecord) -> None: - payload = canonical_json(record.to_dict()) - existing = self._connection.execute( - "SELECT digest FROM source_records WHERE record_id = ?", (record.record_id,) - ).fetchone() - if existing is not None: - if existing["digest"] == record.digest: - return - raise ImmutableRecordError( - f"record {record.record_id!r} already exists with different content" - ) + self.insert_records_atomic((record,)) + + def insert_records_atomic(self, records: Iterable[MemoryRecord]) -> None: + """Insert a batch in one SQLite transaction or insert none of it.""" + + ordered = tuple(records) + payloads: list[tuple[MemoryRecord, str]] = [ + (record, canonical_json(record.to_dict())) for record in ordered + ] with self._connection: - self._connection.execute( - """ - INSERT INTO source_records - (record_id, digest, memory_class, payload_json, created_at) - VALUES (?, ?, ?, ?, ?) - """, - ( - record.record_id, - record.digest, - record.memory_class.value, - payload, - record.created_at, - ), - ) + for record, payload in payloads: + existing = self._connection.execute( + "SELECT digest FROM source_records WHERE record_id = ?", + (record.record_id,), + ).fetchone() + if existing is not None: + if existing["digest"] == record.digest: + continue + raise ImmutableRecordError( + f"record {record.record_id!r} already exists with different content" + ) + self._connection.execute( + """ + INSERT INTO source_records + (record_id, digest, memory_class, payload_json, created_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + record.record_id, + record.digest, + record.memory_class.value, + payload, + record.created_at, + ), + ) def insert_records(self, records: Iterable[MemoryRecord]) -> None: - for record in records: - self.insert_record(record) + self.insert_records_atomic(records) def get_record(self, record_id: str) -> MemoryRecord | None: row = self._connection.execute( @@ -188,6 +218,127 @@ def insert_proposal(self, proposal: ConsolidationProposal) -> None: relations, ) + def insert_proposal_lifecycle(self, event: ProposalLifecycleEvent) -> None: + """Append one governed review event without mutating the proposal row.""" + + proposal = self._connection.execute( + "SELECT status FROM consolidation_proposals WHERE proposal_id = ?", + (event.proposal_id,), + ).fetchone() + if proposal is None: + raise ValueError(f"proposal does not exist: {event.proposal_id}") + previous = proposal["status"] + latest = self._connection.execute( + """ + SELECT status FROM proposal_lifecycle_events + WHERE proposal_id = ? ORDER BY sequence DESC LIMIT 1 + """, + (event.proposal_id,), + ).fetchone() + if latest is not None: + previous = latest["status"] + allowed = { + "proposed": {"reviewed", "challenged", "superseded"}, + "reviewed": {"accepted", "challenged", "superseded"}, + "accepted": {"challenged", "superseded"}, + "challenged": {"reviewed", "accepted", "superseded"}, + "superseded": set(), + } + if event.status not in allowed.get(previous, set()): + raise ValueError(f"invalid proposal lifecycle transition: {previous}->{event.status}") + payload = canonical_json( + { + "event_id": event.event_id, + "proposal_id": event.proposal_id, + "status": event.status, + "created_at": event.created_at, + "reason": event.reason, + } + ) + digest = "sha256:" + hashlib.sha256(payload.encode()).hexdigest() + with self._connection: + existing = self._connection.execute( + "SELECT digest FROM proposal_lifecycle_events WHERE event_id = ?", + (event.event_id,), + ).fetchone() + if existing is not None: + if existing["digest"] == digest: + return + raise ImmutableRecordError( + f"lifecycle event {event.event_id!r} already exists with different content" + ) + self._connection.execute( + """ + INSERT INTO proposal_lifecycle_events + (event_id, proposal_id, status, reason, created_at, digest, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + event.event_id, + event.proposal_id, + event.status, + event.reason, + event.created_at, + digest, + payload, + ), + ) + + def proposal_lifecycle(self, proposal_id: str) -> tuple[ProposalLifecycleEvent, ...]: + rows = self._connection.execute( + """ + SELECT event_id, proposal_id, status, created_at, reason + FROM proposal_lifecycle_events WHERE proposal_id = ? ORDER BY sequence + """, + (proposal_id,), + ).fetchall() + return tuple( + ProposalLifecycleEvent( + event_id=row["event_id"], + proposal_id=row["proposal_id"], + status=row["status"], + created_at=row["created_at"], + reason=row["reason"], + ) + for row in rows + ) + + def search_records( + self, + query: str, + *, + memory_class: MemoryClass | None = None, + include_negative: bool = True, + ) -> tuple[tuple[MemoryRecord, int], ...]: + """Deterministic source retrieval; negative records are included by default.""" + + terms = tuple(sorted(set(re.findall(r"[a-z0-9][a-z0-9_-]*", query.casefold())))) + if not terms: + return () + matches: list[tuple[MemoryRecord, int]] = [] + for record in self.iter_records(memory_class): + if not include_negative and record.memory_class is MemoryClass.NEGATIVE: + continue + haystack = " ".join((record.statement, *record.tags)).casefold() + score = sum(haystack.count(term) for term in terms) + if score: + matches.append((record, score)) + return tuple(sorted(matches, key=lambda item: (-item[1], item[0].record_id))) + + def relation_projection(self) -> tuple[tuple[str, str, str], ...]: + """Rebuild a disposable graph projection from append-only records.""" + + edges: set[tuple[str, str, str]] = set() + for record in self.iter_records(): + for relation, targets in record.relations.items(): + for target in targets: + edges.add((record.record_id, relation, target)) + for row in self._connection.execute( + "SELECT proposal_id, record_id, relation FROM consolidation_members" + ): + edges.add((row["proposal_id"], row["relation"], row["record_id"])) + return tuple(sorted(edges)) + def export_jsonl(self) -> str: """Return a deterministic source-first replay stream.""" @@ -200,6 +351,10 @@ def export_jsonl(self) -> str: "SELECT payload_json FROM consolidation_proposals ORDER BY sequence" ): lines.append(row["payload_json"]) + for row in self._connection.execute( + "SELECT payload_json FROM proposal_lifecycle_events ORDER BY sequence" + ): + lines.append(row["payload_json"]) return "\n".join(lines) + ("\n" if lines else "") @staticmethod @@ -221,4 +376,7 @@ def _decode_record(payload_json: str) -> MemoryRecord: for key, values in payload.get("relations", {}).items() }, metadata=payload.get("metadata", {}), + schema_version=payload.get("schema_version", "ravel-memory-record/0.1"), + evidence_identity=payload.get("evidence_identity"), + experience_identity=payload.get("experience_identity"), ) diff --git a/src/ravel/providers.py b/src/ravel/providers.py new file mode 100644 index 0000000..9aa2f8b --- /dev/null +++ b/src/ravel/providers.py @@ -0,0 +1,127 @@ +"""Narrow RAVEL-to-Forge provider boundary. + +Forge remains the evidence executor and governing system. RAVEL can discover a +provider and submit a request, but it cannot rewrite raw observations or turn a +missing capability into PASS. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal, Mapping, Protocol + +EvidenceStatus = Literal["PASS", "FAIL", "UNKNOWN"] + + +@dataclass(frozen=True, slots=True) +class ProviderCapability: + provider_id: str + operation: str + version: str + deterministic: bool + witness_kind: str + + +@dataclass(frozen=True, slots=True) +class EvidenceRequest: + request_id: str + candidate_id: str + artifact_digest: str + governing_contract: str + verifier_contract: str + question: str + witness_kind: str + resource_budget: Mapping[str, int] = field(default_factory=dict) + timeout_seconds: int = 0 + determinism_required: bool = True + + +@dataclass(frozen=True, slots=True) +class RawEvidence: + request_id: str + provider_id: str + raw_status: EvidenceStatus + observations: Mapping[str, object] + witness_digest: str | None + artifact_digests: tuple[str, ...] + environment_id: str + resource_observations: Mapping[str, object] + limitations: tuple[str, ...] = () + diagnostics: str = "" + + +@dataclass(frozen=True, slots=True) +class EvidenceReceipt: + """RAVEL's bounded disposition over immutable raw provider evidence.""" + + status: EvidenceStatus + reason_code: str + raw: RawEvidence + + +class ForgeProvider(Protocol): + provider_id: str + + def capabilities(self) -> tuple[ProviderCapability, ...]: ... + + def execute(self, request: EvidenceRequest) -> RawEvidence: ... + + +class ForgeAdapter: + """Capability discovery and fail-closed request handling for Forge-like providers.""" + + def __init__(self, providers: tuple[ForgeProvider, ...] = ()) -> None: + self._providers = {provider.provider_id: provider for provider in providers} + + def capabilities(self) -> tuple[ProviderCapability, ...]: + values = [capability for provider in self._providers.values() for capability in provider.capabilities()] + return tuple(sorted(values, key=lambda item: (item.operation, item.provider_id, item.version))) + + def request(self, request: EvidenceRequest) -> EvidenceReceipt: + matches = [ + (provider, capability) + for provider in self._providers.values() + for capability in provider.capabilities() + if capability.operation == request.verifier_contract + and capability.witness_kind == request.witness_kind + and (not request.determinism_required or capability.deterministic) + ] + if not matches: + return EvidenceReceipt( + status="UNKNOWN", + reason_code="capability_unavailable", + raw=RawEvidence( + request_id=request.request_id, + provider_id="ravel-adapter", + raw_status="UNKNOWN", + observations={}, + witness_digest=None, + artifact_digests=(request.artifact_digest,), + environment_id="unavailable", + resource_observations={}, + limitations=("Required Forge capability was unavailable.",), + ), + ) + provider, _ = sorted(matches, key=lambda item: item[0].provider_id)[0] + try: + raw = provider.execute(request) + except Exception as error: # provider failures are explicit UNKNOWN, not dropped + raw = RawEvidence( + request_id=request.request_id, + provider_id=provider.provider_id, + raw_status="UNKNOWN", + observations={}, + witness_digest=None, + artifact_digests=(request.artifact_digest,), + environment_id="provider-failure", + resource_observations={}, + limitations=("Provider execution failed before a governed observation was returned.",), + diagnostics=type(error).__name__, + ) + if raw.request_id != request.request_id: + return EvidenceReceipt("UNKNOWN", "request_identity_mismatch", raw) + if raw.provider_id != provider.provider_id: + return EvidenceReceipt("UNKNOWN", "provider_identity_mismatch", raw) + if raw.raw_status not in {"PASS", "FAIL", "UNKNOWN"}: + return EvidenceReceipt("UNKNOWN", "malformed_raw_status", raw) + return EvidenceReceipt(raw.raw_status, "provider_observation", raw) diff --git a/src/ravel/resources.py b/src/ravel/resources.py new file mode 100644 index 0000000..67c5a47 --- /dev/null +++ b/src/ravel/resources.py @@ -0,0 +1,194 @@ +"""Optional resource-aware placement policy with no ML runtime dependency.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Literal, Protocol + + +class DevicePolicy(str, Enum): + AUTO = "auto" + CPU = "cpu" + CUDA = "cuda" + + +class OffloadPolicy(str, Enum): + NONE = "none" + SEQUENTIAL_CPU = "sequential-cpu" + + +@dataclass(frozen=True, slots=True) +class RuntimePolicy: + device: DevicePolicy = DevicePolicy.AUTO + offload: OffloadPolicy = OffloadPolicy.NONE + gpu_reserve_bytes: int = 0 + maximum_vram_bytes: int | None = None + allow_cpu_fallback: bool = True + allow_oom_recovery: bool = True + + +@dataclass(frozen=True, slots=True) +class ProviderFootprint: + parameter_bytes: int + workspace_bytes: int + peak_module_bytes: int + dtype: str + + +@dataclass(frozen=True, slots=True) +class ResourceSnapshot: + cuda_discovered: bool + cuda_kernel_works: bool + free_vram_bytes: int + total_vram_bytes: int + available_cpu_bytes: int + dtype_supported: bool + process_rss_bytes: int + + +@dataclass(frozen=True, slots=True) +class PlacementDecision: + device: DevicePolicy + offload: OffloadPolicy + reason: str + peak_vram_reservation: int + parameter_residency: str + total_memory_note: str + + +@dataclass(frozen=True, slots=True) +class ExecutionExperience: + """Scoped observation suitable for RAVEL memory, never a universal rule.""" + + provider_id: str + model_id: str + task_class: str + device: DevicePolicy + offload: OffloadPolicy + dtype: str + memory_budget_bytes: int | None + runtime_ms: float | None + outcome: Literal["success", "failure", "unknown"] + verifier_status: Literal["PASS", "FAIL", "UNKNOWN"] + evidence_quality: str + oom: bool = False + + def __post_init__(self) -> None: + if not self.provider_id or not self.model_id or not self.task_class: + raise ValueError("execution experience identity and task scope are required") + if self.memory_budget_bytes is not None and self.memory_budget_bytes < 0: + raise ValueError("memory_budget_bytes must be non-negative") + if self.runtime_ms is not None and self.runtime_ms < 0: + raise ValueError("runtime_ms must be non-negative") + + +class ResourceBackend(Protocol): + def snapshot(self, dtype: str) -> ResourceSnapshot: ... + + def execute(self, placement: PlacementDecision, operation: Callable[[], object]) -> object: ... + + +class CudaOutOfMemory(RuntimeError): + """Optional backend signal used for one bounded placement recovery.""" + + +def _cuda_usable(snapshot: ResourceSnapshot) -> bool: + return snapshot.cuda_discovered and snapshot.cuda_kernel_works and snapshot.dtype_supported + + +def choose_placement( + policy: RuntimePolicy, + footprint: ProviderFootprint, + snapshot: ResourceSnapshot, +) -> PlacementDecision: + """Choose placement from observations; discovery alone never proves execution.""" + + required_full = footprint.parameter_bytes + footprint.workspace_bytes + reserve = max(policy.gpu_reserve_bytes, 0) + available = max(snapshot.free_vram_bytes - reserve, 0) + if policy.maximum_vram_bytes is not None: + available = min(available, max(policy.maximum_vram_bytes - reserve, 0)) + usable = _cuda_usable(snapshot) + if policy.device == DevicePolicy.CPU: + return PlacementDecision( + DevicePolicy.CPU, + OffloadPolicy.NONE, + "cpu_requested", + 0, + "system-ram", + "CPU placement avoids accelerator residency; it is not an algorithmic result.", + ) + if usable and policy.offload == OffloadPolicy.NONE and required_full <= available: + return PlacementDecision( + DevicePolicy.CUDA, + OffloadPolicy.NONE, + "full_cuda_fits_observed_budget", + required_full, + "cuda", + "Parameters remain resident on CUDA for this placement.", + ) + sequential_required = footprint.peak_module_bytes + footprint.workspace_bytes + if usable and policy.offload == OffloadPolicy.SEQUENTIAL_CPU: + if sequential_required <= available and footprint.parameter_bytes <= snapshot.available_cpu_bytes: + return PlacementDecision( + DevicePolicy.CUDA, + OffloadPolicy.SEQUENTIAL_CPU, + "sequential_cpu_offload_fits_observed_budget", + sequential_required, + "system-ram-between-module-executions", + "Sequential offload trades VRAM for system RAM; total memory is not reduced.", + ) + if usable and policy.device == DevicePolicy.AUTO and sequential_required <= available: + return PlacementDecision( + DevicePolicy.CUDA, + OffloadPolicy.SEQUENTIAL_CPU, + "auto_selected_sequential_cpu_offload", + sequential_required, + "system-ram-between-module-executions", + "Sequential offload trades VRAM for system RAM; total memory is not reduced.", + ) + if policy.allow_cpu_fallback: + return PlacementDecision( + DevicePolicy.CPU, + OffloadPolicy.NONE, + "cuda_unavailable_or_budget_insufficient_cpu_fallback", + 0, + "system-ram", + "Fallback is a scoped execution observation, not evidence of superiority.", + ) + return PlacementDecision( + DevicePolicy.CUDA, + policy.offload, + "cuda_required_but_observed_requirements_unmet", + 0, + "unknown", + "Execution must fail closed if the requested placement cannot be established.", + ) + + +def execute_with_bounded_recovery( + backend: ResourceBackend, + policy: RuntimePolicy, + footprint: ProviderFootprint, + operation: Callable[[], object], +) -> tuple[object, PlacementDecision, bool]: + """Execute once, then allow at most one sequential/CPU recovery.""" + + snapshot = backend.snapshot(footprint.dtype) + placement = choose_placement(policy, footprint, snapshot) + try: + return backend.execute(placement, operation), placement, False + except CudaOutOfMemory: + if not policy.allow_oom_recovery or placement.device != DevicePolicy.CUDA: + raise + recovery_policy = RuntimePolicy( + device=DevicePolicy.AUTO, + offload=OffloadPolicy.SEQUENTIAL_CPU, + gpu_reserve_bytes=policy.gpu_reserve_bytes, + maximum_vram_bytes=policy.maximum_vram_bytes, + allow_cpu_fallback=policy.allow_cpu_fallback, + allow_oom_recovery=False, + ) + recovery = choose_placement(recovery_policy, footprint, snapshot) + return backend.execute(recovery, operation), recovery, True diff --git a/tests/test_adaptation.py b/tests/test_adaptation.py new file mode 100644 index 0000000..3fb7db0 --- /dev/null +++ b/tests/test_adaptation.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import unittest + +from ravel.adaptation import ( + RawObservation, + RetentionConstraintPolicy, + canonical_transaction_json, + evaluate_constraints, + run_transaction, +) + + +def observation(**changes: object) -> RawObservation: + values: dict[str, object] = { + "adaptation_objective": 10.0, + "base_accuracy": 0.95, + "representation_score": 0.90, + "original_prediction_degradation": 0.25, + "transition_support_losses": 0, + "expert_count": 70, + "births": 2, + "retirements": 1, + "replay_records": 256, + "update_passes": 1, + "compute_evaluations": 100, + "matched_compute_evaluations": 100, + } + values.update(changes) + return RawObservation(**values) + + +POLICY = RetentionConstraintPolicy( + adaptation_improvement_epsilon=0.05, + base_accuracy_floor=0.85, + representation_floor=0.80, + original_prediction_degradation_bound=1.0, + maximum_transition_support_losses=0, + maximum_experts=80, + maximum_births=16, + maximum_retirements=4, + maximum_replay_records=256, + maximum_update_passes=2, + maximum_compute_evaluations=110, + maximum_compute_ratio=1.10, +) + + +class TransactionTests(unittest.TestCase): + def test_all_constraints_accept_and_commit_candidate_bytes(self) -> None: + previous = observation() + proposed = observation(adaptation_objective=10.10) + transaction = run_transaction( + b"checkpoint-before", + previous, + lambda state: state + b"-candidate", + lambda state: proposed, + POLICY, + ) + self.assertEqual(transaction.status, "accepted") + self.assertEqual(transaction.state_after, b"checkpoint-before-candidate") + self.assertFalse(transaction.report.rejection_reasons) + + def test_each_hard_constraint_has_a_distinct_reason(self) -> None: + cases = { + "adaptation_improvement_below_epsilon": {"adaptation_objective": 10.01}, + "base_accuracy_floor": {"base_accuracy": 0.84, "adaptation_objective": 10.10}, + "representation_floor": {"representation_score": 0.79, "adaptation_objective": 10.10}, + "original_prediction_degradation_bound": { + "original_prediction_degradation": 1.01, + "adaptation_objective": 10.10, + }, + "transition_support_preservation": { + "transition_support_losses": 1, + "adaptation_objective": 10.10, + }, + "expert_capacity_budget": {"expert_count": 81, "adaptation_objective": 10.10}, + "birth_budget": {"births": 17, "adaptation_objective": 10.10}, + "retirement_budget": {"retirements": 5, "adaptation_objective": 10.10}, + "replay_budget": {"replay_records": 257, "adaptation_objective": 10.10}, + "update_pass_budget": {"update_passes": 3, "adaptation_objective": 10.10}, + "compute_budget": {"compute_evaluations": 111, "adaptation_objective": 10.10}, + "matched_compute_ratio": { + "compute_evaluations": 111, + "matched_compute_evaluations": 100, + "adaptation_objective": 10.10, + }, + } + previous = observation() + for reason, changes in cases.items(): + report = evaluate_constraints(previous, observation(**changes), POLICY) + self.assertIn(reason, report.rejection_reasons) + transaction = run_transaction( + b"prior", + previous, + lambda state: state + b"-must-not-commit", + lambda state, changes=changes: observation(**changes), + POLICY, + ) + self.assertEqual(transaction.status, "rejected", reason) + self.assertTrue(transaction.rolled_back_byte_identical, reason) + self.assertEqual(transaction.checkpoint_before_sha256, transaction.checkpoint_after_sha256) + + def test_strong_adaptation_cannot_compensate_for_retention_failure(self) -> None: + report = evaluate_constraints( + observation(), + observation(adaptation_objective=1000.0, base_accuracy=0.1), + POLICY, + ) + self.assertFalse(report.passed) + self.assertEqual(report.rejection_reasons, ("base_accuracy_floor",)) + + def test_rollback_and_record_serialization_are_deterministic(self) -> None: + transaction = run_transaction( + b"prior", + observation(), + lambda state: state + b"-candidate", + lambda state: observation(adaptation_objective=10.01), + POLICY, + ) + self.assertTrue(transaction.rolled_back_byte_identical) + self.assertEqual(canonical_transaction_json(transaction), canonical_transaction_json(transaction)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_consolidation.py b/tests/test_consolidation.py index e81b89d..ef9f0c1 100644 --- a/tests/test_consolidation.py +++ b/tests/test_consolidation.py @@ -10,7 +10,9 @@ MemoryClass, MemoryConsolidator, MemoryRecord, + ProposalLifecycleEvent, RetrievalLayoutPlanner, + ScopeCompatibility, SQLiteMemoryStore, ) @@ -26,10 +28,13 @@ def record( relations: dict[str, tuple[str, ...]] | None = None, status: str = "active", authority_class: str = "repository-local", + memory_class: MemoryClass = MemoryClass.SEMANTIC, + evidence_identity: str | None = None, + experience_identity: str | None = None, ) -> MemoryRecord: return MemoryRecord( record_id=record_id, - memory_class=MemoryClass.SEMANTIC, + memory_class=memory_class, statement=statement, scope=scope or SCOPE, created_at="2026-08-04T16:00:00Z", @@ -37,6 +42,8 @@ def record( authority_class=authority_class, status=status, relations=relations or {}, + evidence_identity=evidence_identity, + experience_identity=experience_identity, ) @@ -107,6 +114,24 @@ def test_output_is_independent_of_input_order(self) -> None: ) self.assertEqual(forward, reverse) + def test_named_scope_contract_can_allow_declared_extra_fields(self) -> None: + records = [ + record("memory:1", "The verifier result remains UNKNOWN."), + record( + "memory:2", + "The verifier result remains UNKNOWN.", + scope={"repository": "epi13/RAVEL", "contract": "other"}, + ), + ] + policy = ConsolidationPolicy( + scope_compatibility=ScopeCompatibility( + contract_id="repository-only/1", + equal_fields=("repository",), + allow_extra_fields=True, + ) + ) + self.assertEqual(len(MemoryConsolidator(policy).propose(records)), 1) + class StoreTests(unittest.TestCase): def test_store_rejects_identity_reuse_with_different_content(self) -> None: @@ -133,6 +158,74 @@ def test_store_persists_sources_before_proposals(self) -> None: self.assertIn('"record_id":"memory:1"', replay[0]) self.assertIn('"proposal_id":', replay[2]) + def test_atomic_batch_rolls_back_when_a_later_record_conflicts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + with SQLiteMemoryStore(f"{directory}/memory.sqlite3") as store: + store.insert_record(record("memory:existing", "Original.")) + with self.assertRaises(ImmutableRecordError): + store.insert_records_atomic( + [ + record("memory:new", "Must roll back."), + record("memory:existing", "Changed."), + ] + ) + self.assertIsNone(store.get_record("memory:new")) + + def test_search_keeps_negative_source_and_rebuilds_relations(self) -> None: + with tempfile.TemporaryDirectory() as directory: + with SQLiteMemoryStore(f"{directory}/memory.sqlite3") as store: + store.insert_records_atomic( + [ + record( + "memory:success", + "CUDA execution succeeds within the memory budget.", + evidence_identity="ravel-evidence:1", + experience_identity="ravel-experience:1", + ), + record( + "memory:failure", + "CUDA execution fails with an out of memory error.", + memory_class=MemoryClass.NEGATIVE, + relations={"contradicts": ("memory:success",)}, + ), + ] + ) + results = store.search_records("CUDA execution memory") + self.assertEqual( + [item[0].record_id for item in results], + ["memory:failure", "memory:success"], + ) + self.assertEqual( + store.relation_projection(), + (("memory:failure", "contradicts", "memory:success"),), + ) + self.assertEqual( + store.get_record("memory:success").evidence_identity, + "ravel-evidence:1", + ) + + def test_proposal_lifecycle_is_append_only_and_ordered(self) -> None: + records = [ + record("memory:1", "RAVEL keeps source history."), + record("memory:2", "RAVEL keeps source history."), + ] + proposal = MemoryConsolidator().propose(records, created_at="2026-08-04T17:00:00Z")[0] + with tempfile.TemporaryDirectory() as directory: + with SQLiteMemoryStore(f"{directory}/memory.sqlite3") as store: + store.insert_records(records) + store.insert_proposal(proposal) + store.insert_proposal_lifecycle( + ProposalLifecycleEvent("event:1", proposal.proposal_id, "reviewed", "2026-08-04T18:00:00Z", "reviewed") + ) + store.insert_proposal_lifecycle( + ProposalLifecycleEvent("event:2", proposal.proposal_id, "accepted", "2026-08-04T19:00:00Z", "accepted for retrieval") + ) + self.assertEqual( + [event.status for event in store.proposal_lifecycle(proposal.proposal_id)], + ["reviewed", "accepted"], + ) + self.assertIn('"status":"accepted"', store.export_jsonl()) + class RetrievalLayoutTests(unittest.TestCase): def test_frequent_coaccess_forms_rebuildable_bucket(self) -> None: diff --git a/tests/test_providers_resources.py b/tests/test_providers_resources.py new file mode 100644 index 0000000..228db45 --- /dev/null +++ b/tests/test_providers_resources.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import unittest + +from ravel.providers import EvidenceRequest, ForgeAdapter, ProviderCapability, RawEvidence +from ravel.resources import ( + CudaOutOfMemory, + DevicePolicy, + OffloadPolicy, + PlacementDecision, + ProviderFootprint, + ResourceSnapshot, + RuntimePolicy, + ExecutionExperience, + choose_placement, + execute_with_bounded_recovery, +) + + +class FakeProvider: + provider_id = "fake-forge" + + def __init__(self, raw: RawEvidence | None = None) -> None: + self.raw = raw + + def capabilities(self) -> tuple[ProviderCapability, ...]: + return (ProviderCapability("fake-forge", "compile", "1", True, "diagnostic"),) + + def execute(self, request: EvidenceRequest) -> RawEvidence: + if self.raw is not None: + return self.raw + return RawEvidence( + request.request_id, + self.provider_id, + "FAIL", + {"exit_status": 1}, + "sha256:witness", + (request.artifact_digest,), + "fake-host", + {}, + ) + + +REQUEST = EvidenceRequest("request:1", "candidate:1", "sha256:artifact", "mncs:v1", "compile", "does it compile?", "diagnostic") + + +class ProviderTests(unittest.TestCase): + def test_adapter_preserves_raw_fail(self) -> None: + receipt = ForgeAdapter((FakeProvider(),)).request(REQUEST) + self.assertEqual(receipt.status, "FAIL") + self.assertEqual(receipt.raw.observations["exit_status"], 1) + + def test_missing_capability_is_unknown(self) -> None: + receipt = ForgeAdapter(()).request(REQUEST) + self.assertEqual((receipt.status, receipt.reason_code), ("UNKNOWN", "capability_unavailable")) + + +SNAPSHOT = ResourceSnapshot(True, True, 8_000, 12_000, 64_000, True, 1_000) +FOOTPRINT = ProviderFootprint(10_000, 1_000, 3_000, "bf16") + + +class ResourceTests(unittest.TestCase): + def test_execution_experience_keeps_strategy_scope_and_verifier_status(self) -> None: + experience = ExecutionExperience( + "provider:1", + "model:1", + "toy-task", + DevicePolicy.CUDA, + OffloadPolicy.SEQUENTIAL_CPU, + "bf16", + 8_000, + 123.0, + "success", + "UNKNOWN", + "provider-observation", + ) + self.assertEqual(experience.verifier_status, "UNKNOWN") + self.assertEqual(experience.offload, OffloadPolicy.SEQUENTIAL_CPU) + + def test_full_cuda_requires_observed_kernel_and_budget(self) -> None: + decision = choose_placement(RuntimePolicy(device=DevicePolicy.CUDA), FOOTPRINT, SNAPSHOT) + self.assertEqual((decision.device, decision.offload), (DevicePolicy.CPU, OffloadPolicy.NONE)) + + def test_sequential_offload_records_ram_tradeoff(self) -> None: + decision = choose_placement( + RuntimePolicy(device=DevicePolicy.CUDA, offload=OffloadPolicy.SEQUENTIAL_CPU), + FOOTPRINT, + SNAPSHOT, + ) + self.assertEqual((decision.device, decision.offload), (DevicePolicy.CUDA, OffloadPolicy.SEQUENTIAL_CPU)) + self.assertIn("system RAM", decision.total_memory_note) + + def test_oom_recovery_is_bounded(self) -> None: + class Backend: + def snapshot(self, dtype: str) -> ResourceSnapshot: + return ResourceSnapshot(True, True, 20_000, 24_000, 64_000, True, 1_000) + + def execute(self, placement: PlacementDecision, operation): + if placement.offload == OffloadPolicy.NONE: + raise CudaOutOfMemory() + return "ok" + + result, placement, recovered = execute_with_bounded_recovery( + Backend(), RuntimePolicy(device=DevicePolicy.CUDA, allow_oom_recovery=True), FOOTPRINT, lambda: None + ) + self.assertEqual(result, "ok") + self.assertTrue(recovered) + self.assertEqual(placement.offload, OffloadPolicy.SEQUENTIAL_CPU) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ravel_0_6_provenance.py b/tests/test_ravel_0_6_provenance.py new file mode 100644 index 0000000..4ab9ffe --- /dev/null +++ b/tests/test_ravel_0_6_provenance.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from tools.ravel_0_6_build import BuildError, build, derive +from tools.ravel_0_6_seed_candidate import FROZEN_SOURCE, SeedError, build_candidate_source + + +class CandidateProvenanceTests(unittest.TestCase): + def test_repeated_derivation_is_byte_identical(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "first.c" + second = root / "second.c" + self.assertEqual(derive(first), derive(second)) + self.assertEqual(first.read_bytes(), second.read_bytes()) + + def test_frozen_source_mutation_is_rejected(self) -> None: + mutated = FROZEN_SOURCE.read_bytes() + b"\n/* mutation */\n" + with self.assertRaises(SeedError): + build_candidate_source(mutated) + + def test_stale_generated_outputs_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "candidate.c" + output.write_text("stale", encoding="utf-8") + with self.assertRaises(BuildError): + derive(output) + build_dir = root / "build" + build_dir.mkdir() + (build_dir / "ravel_0_6_candidate_001.c").write_text("stale", encoding="utf-8") + with self.assertRaises(BuildError): + build(build_dir) + + def test_build_record_binds_generator_compiler_and_raw_status(self) -> None: + with tempfile.TemporaryDirectory() as directory: + record = build(Path(directory)) + self.assertEqual(record["candidate_id"], "ravel-0.6-candidate-001") + self.assertTrue(record["generator"]["sha256"]) + self.assertTrue(record["compiler"]["argv"]) + self.assertEqual(record["build"]["exit_status"], 0) + self.assertFalse(record["authoritative_evidence"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/README.md b/tools/README.md index ed95679..33364b8 100644 --- a/tools/README.md +++ b/tools/README.md @@ -21,6 +21,8 @@ modules or historical logical paths. | `ravel_0_5_evidence.py` | Orchestrate 0.5 generation, verification, mutation tests, development gates, manifest tests, and runtime capture | | `ravel_0_5_source_digest.py` | Verify 0.5 source and execution identity | | `ravel_0_6_seed_candidate.py` | Derive bounded 0.6 candidate-001 development source from an exact SHA-256-bound 0.5 input | +| `ravel_0_6_build.py` | Generate and compile candidate-001 in a temporary directory with provenance-bound raw build output | +| `ravel_0_6_behavioral_fixtures.py` | Run compiled slot-one planning and birth-support fixtures plus bounded reverted-correction mutations | Other support modules in this directory should follow the same separation: mechanism execution emits facts; evaluators derive results; digest tools bind @@ -98,6 +100,17 @@ The current candidate-001 corrections expand planning traversal to all declared transition slots and remove inherited empirical support from adaptation births. See `../ravel_versions/0.6/RAVEL_0_6_NEXT_STEPS.md` for the remaining lifecycle and evidence work. +The corresponding read-only development checks are: + +```bash +make -f ravel_versions/0.6/Makefile build +make -f ravel_versions/0.6/Makefile behavioral-test +``` + +Both produce development observations only. They do not consume selection or +future-final partitions, freeze a candidate, or create evidence of independent +evaluation or promotion. + ## Adding or changing a tool A tooling change should state: diff --git a/tools/ravel_0_6_behavioral_fixtures.c b/tools/ravel_0_6_behavioral_fixtures.c new file mode 100644 index 0000000..3936f0f --- /dev/null +++ b/tools/ravel_0_6_behavioral_fixtures.c @@ -0,0 +1,85 @@ +/* Behavioral fixtures compiled against either frozen 0.5 or candidate 001. */ +#ifndef RAVEL_06_CANDIDATE_SOURCE +#error "compile with -DRAVEL_06_CANDIDATE_SOURCE=\"path\"" +#endif + +#define main ravel_candidate_main +#include RAVEL_06_CANDIDATE_SOURCE +#undef main + +static void fixture_model(Model *model, const Event *event) { + memset(model, 0, sizeof *model); + model->n = 3u; + seed_expert(&model->e[0], event, UINT64_C(0x101), 0u, 0u); + seed_expert(&model->e[1], event, UINT64_C(0x102), 0u, 0u); + seed_expert(&model->e[2], event, UINT64_C(0x103), 0u, 0u); + for (uint16_t expert = 0u; expert < model->n; ++expert) { + for (uint32_t action = 0u; action < ACTIONS; ++action) { + for (uint32_t slot = 0u; slot < TRANSITION_TOP_K; ++slot) { + model->next_graph[expert][action][slot] = INVALID_EXPERT; + model->next_graph_support[expert][action][slot] = 0u; + } + } + } +} + +static int slot_one_route_fixture(const Event *event) { + Model model; + fixture_model(&model, event); + model.next_graph[0][0][1] = 1u; + model.next_graph_support[0][0][1] = TRANSITION_SUPPORT_MIN; + model.next_graph[1][0][0] = 2u; + model.next_graph_support[1][0][0] = TRANSITION_SUPPORT_MIN; + uint8_t actions[PLAN_LIMIT] = {0}; + uint32_t used = 0u; + uint64_t expansions = 0u; + uint64_t unknown = 0u; + return plan_actions(&model, 0u, 2u, actions, &used, &expansions, &unknown) && + used == 2u && actions[0] == 0u && actions[1] == 0u; +} + +static int birth_support_fixture(const Event *event) { + Model model; + fixture_model(&model, event); + model.n = 1u; + Expert *parent = &model.e[0]; + parent->count = 77u; + parent->errors = 11u; + parent->labels[(event->label + 1u) % CLASSES] = 9u; + parent->action_count[(event->action + 1u) % ACTIONS] = 6u; + parent->transition_target[(event->action + 1u) % ACTIONS][0] = 0u; + parent->transition_support[(event->action + 1u) % ACTIONS][0] = 6u; + seed_adaptation_expert(&model, 1u, event, 19u); + model.n = 2u; + compile_graph(&model); + const Expert *child = &model.e[1]; + uint32_t unrelated_action = (event->action + 1u) % ACTIONS; + int unrelated_support_absent = + child->count == 1u && + child->action_count[unrelated_action] == 0u && + child->transition_target[unrelated_action][0] == INVALID_EXPERT && + child->transition_support[unrelated_action][0] == 0u; + int unsupported_action_unknown = + child->action_count[unrelated_action] < TRANSITION_SUPPORT_MIN && + model.next_graph[1][unrelated_action][0] == INVALID_EXPERT; + return unrelated_support_absent && unsupported_action_unknown; +} + +int main(void) { + Event event; + memset(&event, 0, sizeof event); + event.action = 0u; + event.label = 1u; + event.x[0] = 3; + event.nx[0] = 4; + int slot_one = slot_one_route_fixture(&event); + int birth = birth_support_fixture(&event); + uint64_t checksum = mix64((uint64_t)slot_one ^ + ((uint64_t)birth << 8) ^ + UINT64_C(0x5236303346495854)); + printf("{\"schema\":\"ravel-0.6-behavioral-fixtures/1\"," + "\"slot_one_route\":%d,\"birth_support_reset\":%d," + "\"checksum\":\"%016" PRIx64 "\"}\n", + slot_one, birth, checksum); + return (slot_one && birth) ? 0 : 1; +} diff --git a/tools/ravel_0_6_behavioral_fixtures.py b/tools/ravel_0_6_behavioral_fixtures.py new file mode 100644 index 0000000..fdd8d33 --- /dev/null +++ b/tools/ravel_0_6_behavioral_fixtures.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Run RAVEL 0.6 behavioral fixtures and bounded mutation checks.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import tempfile + +from ravel_0_6_seed_candidate import ( + FROZEN_SOURCE, + NEW_PLANNER_CONTEXT, + NEW_SEED_FUNCTION, + OLD_PLANNER_CONTEXT, + OLD_SEED_FUNCTION, + build_candidate_source, +) + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = Path(__file__).with_name("ravel_0_6_behavioral_fixtures.c") + + +def compile_and_run(source: Path, directory: Path) -> tuple[int, dict[str, object] | None]: + binary = directory / (source.stem + ".bin") + command = [ + "cc", + "-std=c11", + "-O0", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f'-DRAVEL_06_CANDIDATE_SOURCE="{source}"', + str(HARNESS), + "-lm", + "-o", + str(binary), + ] + built = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False) + if built.returncode != 0: + raise AssertionError(f"fixture compile failed for {source}: {built.stderr}") + run = subprocess.run([str(binary)], cwd=ROOT, text=True, capture_output=True, check=False) + payload = json.loads(run.stdout) if run.stdout.strip() else None + return run.returncode, payload + + +def expect_pass(source: Path, directory: Path) -> dict[str, object]: + status, payload = compile_and_run(source, directory) + if status != 0 or payload is None: + raise AssertionError(f"behavioral fixture unexpectedly failed for {source}: {payload}") + if payload.get("slot_one_route") != 1 or payload.get("birth_support_reset") != 1: + raise AssertionError(f"behavioral fixture facts are not both true: {payload}") + return payload + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="ravel-0.6-fixtures-") as name: + directory = Path(name) + frozen = directory / "ravel_0_5.c" + candidate = directory / "ravel_0_6_candidate_001.c" + mutated_planner = directory / "mutated_planner.c" + mutated_birth = directory / "mutated_birth.c" + frozen.write_bytes(FROZEN_SOURCE.read_bytes()) + candidate.write_text(build_candidate_source(FROZEN_SOURCE.read_bytes()), encoding="utf-8") + mutated_planner.write_text( + candidate.read_text(encoding="utf-8").replace(NEW_PLANNER_CONTEXT, OLD_PLANNER_CONTEXT, 1), + encoding="utf-8", + ) + mutated_birth.write_text( + candidate.read_text(encoding="utf-8").replace(NEW_SEED_FUNCTION, OLD_SEED_FUNCTION, 1), + encoding="utf-8", + ) + candidate_payload = expect_pass(candidate, directory) + frozen_status, frozen_payload = compile_and_run(frozen, directory) + planner_status, planner_payload = compile_and_run(mutated_planner, directory) + birth_status, birth_payload = compile_and_run(mutated_birth, directory) + if frozen_status == 0 or planner_status == 0 or birth_status == 0: + raise AssertionError( + "0.5 behavior or a reverted correction unexpectedly passed: " + f"frozen={frozen_payload} planner={planner_payload} birth={birth_payload}" + ) + print( + json.dumps( + { + "schema": "ravel-0.6-behavioral-fixtures-result/1", + "candidate": candidate_payload, + "frozen_0_5": frozen_payload, + "mutated_planner": planner_payload, + "mutated_birth": birth_payload, + "candidate_pass": True, + "negative_pass": True, + }, + sort_keys=True, + separators=(",", ":"), + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ravel_0_6_build.py b/tools/ravel_0_6_build.py new file mode 100644 index 0000000..fb6d581 --- /dev/null +++ b/tools/ravel_0_6_build.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Build the reproducible RAVEL 0.6 candidate as development material only. + +The generated source and executable are deliberately placed in a caller-owned +temporary directory. The JSON record binds the frozen 0.5 input, generator, +compiler invocation, selected environment identities, and raw build output; +it does not turn a successful build into evaluation evidence. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +from typing import Any + +try: + from .ravel_0_6_seed_candidate import ( + FROZEN_SOURCE, + FROZEN_SOURCE_SHA256, + build_candidate_source, + ) +except ImportError: # direct script execution from the tools directory + from ravel_0_6_seed_candidate import ( # type: ignore[no-redef] + FROZEN_SOURCE, + FROZEN_SOURCE_SHA256, + build_candidate_source, + ) + +ROOT = Path(__file__).resolve().parents[1] +GENERATOR = Path(__file__).resolve() +CANDIDATE_ID = "ravel-0.6-candidate-001" +ENVIRONMENT_KEYS = ("CC", "CFLAGS", "CPPFLAGS", "LDFLAGS", "LC_ALL", "LANG") +CANONICAL_FLAGS = ("-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-pedantic") + + +class BuildError(RuntimeError): + """Raised when a development build cannot be identity-checked.""" + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + return sha256_bytes(path.read_bytes()) + + +def canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def environment_identities() -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for key in ENVIRONMENT_KEYS: + value = os.environ.get(key) + result[key] = { + "present": value is not None, + "sha256": sha256_bytes(value.encode("utf-8")) if value is not None else None, + } + return result + + +def compiler_command() -> list[str]: + configured = os.environ.get("CC", "cc") + command = shlex.split(configured) + if len(command) != 1 or not command[0]: + raise BuildError("CC must name one compiler executable without arguments") + executable = shutil.which(command[0]) + if executable is None: + raise BuildError(f"compiler executable is unavailable: {command[0]}") + return [executable] + + +def run_capture(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(argv, cwd=ROOT, text=True, capture_output=True, check=False) + + +def worktree_status() -> list[str]: + result = run_capture(["git", "status", "--porcelain", "--untracked-files=all"]) + if result.returncode != 0: + raise BuildError(f"git status failed: {result.stderr.strip()}") + return result.stdout.splitlines() + + +def build(output_dir: Path, *, require_clean_worktree: bool = False) -> dict[str, Any]: + output_dir = output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + source_path = output_dir / "ravel_0_6_candidate_001.c" + binary_path = output_dir / "ravel_0_6_candidate_001" + record_path = output_dir / "ravel-0.6-candidate-001-build.json" + + existing = [path for path in (source_path, binary_path, record_path) if path.exists()] + if existing: + raise BuildError( + "stale generated output exists; use a new empty output directory: " + + ", ".join(str(path) for path in existing) + ) + if require_clean_worktree: + status = worktree_status() + if status: + raise BuildError("clean-worktree check failed: " + " | ".join(status)) + + source = build_candidate_source(FROZEN_SOURCE.read_bytes()).encode("utf-8") + source_path.write_bytes(source) + compiler = compiler_command() + version = run_capture(compiler + ["--version"]) + argv = compiler + list(CANONICAL_FLAGS) + [str(source_path), "-lm", "-o", str(binary_path)] + result = run_capture(argv) + record: dict[str, Any] = { + "schema": "ravel-0.6-development-build/0.1", + "candidate_id": CANDIDATE_ID, + "authoritative_evidence": False, + "evaluation_status": "UNKNOWN", + "baseline": { + "candidate_id": "ravel-0.5-candidate-1", + "source_path": "ravel_versions/0.5/ravel_0_5.c", + "source_sha256": FROZEN_SOURCE_SHA256, + }, + "generator": { + "path": str(GENERATOR.relative_to(ROOT)), + "sha256": sha256_file(GENERATOR), + "seed_builder": "tools/ravel_0_6_seed_candidate.py", + }, + "generated_source": { + "path": str(source_path), + "sha256": sha256_bytes(source), + "bytes": len(source), + "development_only": True, + }, + "compiler": { + "executable": compiler[0], + "version_argv": compiler + ["--version"], + "version_stdout": version.stdout, + "version_stderr": version.stderr, + "version_exit_status": version.returncode, + "argv": argv, + }, + "environment_keys": environment_identities(), + "worktree": { + "required_clean": require_clean_worktree, + "status": worktree_status(), + }, + "build": { + "stdout": result.stdout, + "stderr": result.stderr, + "exit_status": result.returncode, + "binary_sha256": sha256_file(binary_path) if result.returncode == 0 else None, + }, + } + record_path.write_text(canonical_json(record) + "\n", encoding="utf-8") + if version.returncode != 0: + raise BuildError("compiler version probe failed") + if result.returncode != 0: + raise BuildError("candidate compilation failed; see " + str(record_path)) + return record + + +def derive(output: Path) -> str: + if output.exists(): + raise BuildError(f"stale generated source exists: {output}") + output.parent.mkdir(parents=True, exist_ok=True) + source = build_candidate_source(FROZEN_SOURCE.read_bytes()).encode("utf-8") + output.write_bytes(source) + return sha256_bytes(source) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + build_parser = subparsers.add_parser("build") + build_parser.add_argument("--output-dir", type=Path, required=True) + build_parser.add_argument("--require-clean-worktree", action="store_true") + derive_parser = subparsers.add_parser("derive") + derive_parser.add_argument("--output", type=Path, required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + try: + if args.command == "derive": + digest = derive(args.output) + print(f"{CANDIDATE_ID} sha256={digest}") + else: + record = build(args.output_dir, require_clean_worktree=args.require_clean_worktree) + print(canonical_json(record)) + except (BuildError, OSError, UnicodeError, ValueError) as error: + print(f"ravel 0.6 development build failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())