diff --git a/benchmarks/skeleton_compressor_bench.py b/benchmarks/skeleton_compressor_bench.py index 271862e89..a7b982dec 100644 --- a/benchmarks/skeleton_compressor_bench.py +++ b/benchmarks/skeleton_compressor_bench.py @@ -22,9 +22,15 @@ import cleveragents # noqa: E402 importlib.reload(cleveragents) from cleveragents.application.services.skeleton_compressor import ( # noqa: E402 - ContextFragment, SkeletonCompressorService, ) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextFragment, + FragmentProvenance, +) + +# Default provenance for skeleton benchmark fragments. +_SKEL_PROV = FragmentProvenance(resource_uri="bench-skeleton://default") def _build_fragments(count: int, tokens_each: int = 100) -> list[ContextFragment]: @@ -32,10 +38,14 @@ def _build_fragments(count: int, tokens_each: int = 100) -> list[ContextFragment return [ ContextFragment( fragment_id=f"bench-{i:05d}", + uko_node=f"bench-skeleton://file/{i}", content="x" * tokens_each, token_count=tokens_each, - relevance=round(1.0 - (i / max(count, 1)), 4), - source_decision_id=f"01HX{'B' * 22}{i:02d}" if i < 99 else None, + relevance_score=round(1.0 - (i / max(count, 1)), 4), + provenance=_SKEL_PROV, + metadata=( + {"source_decision_id": f"01HX{'B' * 22}{i:02d}"} if i < 99 else {} + ), ) for i in range(count) ] diff --git a/benchmarks/unified_context_models_bench.py b/benchmarks/unified_context_models_bench.py new file mode 100644 index 000000000..a3512f205 --- /dev/null +++ b/benchmarks/unified_context_models_bench.py @@ -0,0 +1,122 @@ +"""ASV benchmarks for unified CRP/core context model hierarchy. + +Measures the construction throughput for core types that extend CRP base +types via Pydantic v2 inheritance, and the isinstance-check overhead. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload the top-level package so Python picks up the source tree +# version instead of the potentially stale installed copy. +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + AssembledContext as CRPAssembledContext, + ContextBudget as CRPContextBudget, + ContextFragment as CRPContextFragment, + FragmentProvenance as CRPFragmentProvenance, +) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextBudget, + ContextFragment, + ContextPayload, + FragmentProvenance, +) + + +class TimeUnifiedModelCreation: + """Benchmark unified model construction throughput.""" + + timeout = 60 + + def setup(self) -> None: + """Prepare reusable inputs.""" + self.provenance = FragmentProvenance( + resource_uri="uko-py:module/bench", + strategy="benchmark-strategy", + resource_type="git-checkout", + ) + + def time_fragment_provenance(self) -> None: + """Create core FragmentProvenance (inherits CRP).""" + for _ in range(1000): + FragmentProvenance( + resource_uri="uko-py:module/bench", + strategy="arce", + resource_type="git-checkout", + ) + + def time_context_fragment(self) -> None: + """Create core ContextFragment (inherits CRP).""" + for _ in range(1000): + ContextFragment( + uko_node="project://bench", + content="benchmark fragment", + token_count=100, + tier="hot", + provenance=self.provenance, + ) + + def time_context_budget(self) -> None: + """Create core ContextBudget (inherits CRP).""" + for _ in range(1000): + b = ContextBudget(max_tokens=8192, reserved_tokens=1024) + _ = b.available_tokens + + def time_context_payload(self) -> None: + """Create core ContextPayload (inherits CRP AssembledContext).""" + for _ in range(1000): + ContextPayload(plan_id="01JQBENCHPN00000000000000AA") + + +class TimeIsinstanceChecks: + """Benchmark isinstance compatibility checks.""" + + timeout = 60 + + def setup(self) -> None: + """Prepare instances.""" + prov = FragmentProvenance(resource_uri="uko-py:module/bench") + self.provenance = prov + self.fragment = ContextFragment( + uko_node="project://bench", + content="benchmark fragment", + token_count=100, + provenance=prov, + ) + self.budget = ContextBudget(max_tokens=4096, reserved_tokens=512) + self.payload = ContextPayload( + plan_id="01JQBENCHPN00000000000000AA", + ) + + def time_isinstance_provenance(self) -> None: + """isinstance check: FragmentProvenance against CRP base.""" + for _ in range(10000): + isinstance(self.provenance, CRPFragmentProvenance) + + def time_isinstance_fragment(self) -> None: + """isinstance check: ContextFragment against CRP base.""" + for _ in range(10000): + isinstance(self.fragment, CRPContextFragment) + + def time_isinstance_budget(self) -> None: + """isinstance check: ContextBudget against CRP base.""" + for _ in range(10000): + isinstance(self.budget, CRPContextBudget) + + def time_isinstance_payload(self) -> None: + """isinstance check: ContextPayload against CRP AssembledContext.""" + for _ in range(10000): + isinstance(self.payload, CRPAssembledContext) diff --git a/docs/reference/acms.md b/docs/reference/acms.md index 5de78503f..bb8abead1 100644 --- a/docs/reference/acms.md +++ b/docs/reference/acms.md @@ -164,6 +164,7 @@ milestones: | `FragmentScorer.score()` | Missing `plan_context` param; returns `Sequence[ContextFragment]` | Returns `list[ScoredFragment]` with `plan_context` | Future milestone | | `PreambleGenerator.generate()` | Missing `strategies_used`, `budget_used`, `max_tokens` params | Full parameter set per spec §42795 | Future milestone | | `provenance_map` keying | Keyed by `fragment_id` (ULID) | Spec keys by `uko_node` (UKO URI) | Under review — `fragment_id` avoids collisions when multiple fragments share a `uko_node` | +| CRP base model mutability | `FragmentProvenance`, `ContextFragment`, `ContextBudget`, `AssembledContext` are `frozen=True` | CRP models may be mutable | Core types extend CRP bases via Pydantic v2 inheritance; freezing CRP bases ensures consistent immutability across the hierarchy. No CRP consumer mutates these instances. | ## Extension Points diff --git a/features/skeleton_compressor.feature b/features/skeleton_compressor.feature index 9a6488e99..793ad86aa 100644 --- a/features/skeleton_compressor.feature +++ b/features/skeleton_compressor.feature @@ -132,3 +132,20 @@ Feature: Skeleton compressor Given a skeleton metadata with ratio 0.5 and 1000 original tokens and 500 compressed When I attach skeleton_metadata to a plan Then the plan should expose skeleton metadata in cli dict + + # --- _validate_fragments edge-cases (lines 153-163) -------------------- + + Scenario: Reject fragment with negative token_count + Given a context fragment constructed with negative token_count + When I validate the invalid fragments + Then the compressor should raise a ValueError mentioning "token_count must be >= 0" + + Scenario: Reject fragment with out-of-range relevance_score + Given a context fragment constructed with relevance_score 1.5 + When I validate the invalid fragments + Then the compressor should raise a ValueError mentioning "relevance_score must be in" + + Scenario: Reject fragment with empty fragment_id + Given a context fragment constructed with empty fragment_id + When I validate the invalid fragments + Then the compressor should raise a ValueError mentioning "fragment_id must be non-empty" diff --git a/features/steps/plan_commands_new_coverage_steps.py b/features/steps/plan_commands_new_coverage_steps.py index 55f96228e..6688ff8cf 100644 --- a/features/steps/plan_commands_new_coverage_steps.py +++ b/features/steps/plan_commands_new_coverage_steps.py @@ -218,7 +218,9 @@ def step_new_cov_output_contains(context: Context, text: str) -> None: @then("new_cov the returned object should be a PlanApplyService instance") def step_new_cov_result_is_pas(context: Context) -> None: - assert context.new_cov_apply_result is context.new_cov_pas_instance + assert context.new_cov_apply_result is context.new_cov_pas_instance, ( + f"Expected the mock PAS instance, got {type(context.new_cov_apply_result).__name__}" + ) @then("new_cov PlanApplyService was constructed with the lifecycle service") diff --git a/features/steps/plan_lifecycle_cli_steps.py b/features/steps/plan_lifecycle_cli_steps.py index a66876313..60d7ac4f5 100644 --- a/features/steps/plan_lifecycle_cli_steps.py +++ b/features/steps/plan_lifecycle_cli_steps.py @@ -121,8 +121,12 @@ def step_call_lifecycle_service_helper(context) -> None: @then("the lifecycle service helper should return a plan lifecycle service") def step_lifecycle_service_helper_returns(context) -> None: - assert isinstance(context.helper_service, PlanLifecycleService) - assert context.helper_service.settings is context.helper_settings + svc = context.helper_service + assert isinstance(svc, PlanLifecycleService), ( + f"Expected PlanLifecycleService, got {type(svc).__name__} " + f"(module={type(svc).__module__})" + ) + assert svc.settings is context.helper_settings @when("I run plan lifecycle use with parsed arguments") diff --git a/features/steps/project_commands_coverage_steps.py b/features/steps/project_commands_coverage_steps.py index 7596ecaec..482bf8352 100644 --- a/features/steps/project_commands_coverage_steps.py +++ b/features/steps/project_commands_coverage_steps.py @@ -276,33 +276,21 @@ def step_execute_project_status_error(context): def step_run_project_list(context): """Run project list command. - Ensures the default database path is writable by creating the - ``.cleveragents`` directory and bootstrapping the schema so the - ``list`` sub-command can query the ``ns_projects`` table even - when no projects exist. + Mocks the project repository to return an empty list so the test + is fully isolated from database state left by prior scenarios + (which caused flaky failures in sequential / coverage mode). """ - import os + from unittest.mock import MagicMock, patch - from sqlalchemy import create_engine - - from cleveragents.infrastructure.database.models import Base - - # Ensure the fallback database directory exists inside the temp CWD - db_dir = Path.cwd() / ".cleveragents" - db_dir.mkdir(parents=True, exist_ok=True) - db_path = db_dir / "db.sqlite" - - # Point the container at this database so it doesn't fail on open - db_url = f"sqlite:///{db_path.absolute()}" - os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url - - # Bootstrap the schema so the table exists for the query - engine = create_engine(db_url, echo=False) - Base.metadata.create_all(engine) - engine.dispose() + mock_repo = MagicMock() + mock_repo.list_projects.return_value = [] runner = CliRunner() - result = runner.invoke(project.app, ["list"]) + with patch( + "cleveragents.cli.commands.project._get_namespaced_project_repo", + return_value=mock_repo, + ): + result = runner.invoke(project.app, ["list"]) context.result = result diff --git a/features/steps/security_async_steps.py b/features/steps/security_async_steps.py index 338f8e103..12be6542a 100644 --- a/features/steps/security_async_steps.py +++ b/features/steps/security_async_steps.py @@ -45,7 +45,15 @@ def step_create_tracker(context): context.tracker = AsyncResourceTracker() context.resources = {} context.log_handler = _CapturingHandler() - logging.getLogger("cleveragents.core.async_cleanup").addHandler(context.log_handler) + _logger = logging.getLogger("cleveragents.core.async_cleanup") + # Remove stale handlers from previous scenarios (sequential mode) + for h in list(_logger.handlers): + if isinstance(h, _CapturingHandler): + _logger.removeHandler(h) + _logger.addHandler(context.log_handler) + # Ensure the logger propagates warnings even if a prior test disabled them. + _logger.setLevel(logging.DEBUG) + _logger.disabled = False context.warnings_logged = context.log_handler.records diff --git a/features/steps/skeleton_compressor_steps.py b/features/steps/skeleton_compressor_steps.py index a808d6890..c84877935 100644 --- a/features/steps/skeleton_compressor_steps.py +++ b/features/steps/skeleton_compressor_steps.py @@ -4,12 +4,16 @@ from __future__ import annotations from behave import given, then, when from behave.runner import Context +from pydantic import ValidationError from cleveragents.application.services.skeleton_compressor import ( DEFAULT_SKELETON_RATIO, - ContextFragment, SkeletonCompressorService, ) +from cleveragents.domain.models.core.context_fragment import ( + ContextFragment, + FragmentProvenance, +) from cleveragents.domain.models.core.plan import ( NamespacedName, Plan, @@ -21,6 +25,31 @@ from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata # --- helpers --------------------------------------------------------------- +# Default provenance for skeleton test fragments. +_SKEL_PROV = FragmentProvenance(resource_uri="skeleton://test") + + +def _make_skel_fragment( + fragment_id: str, + content: str, + token_count: int, + relevance_score: float, + source_decision_id: str | None = None, +) -> ContextFragment: + """Create a ContextFragment suitable for skeleton compression tests.""" + metadata: dict[str, str] = {} + if source_decision_id is not None: + metadata["source_decision_id"] = source_decision_id + return ContextFragment( + fragment_id=fragment_id, + uko_node=f"skeleton://{fragment_id}", + content=content, + token_count=token_count, + relevance_score=relevance_score, + provenance=_SKEL_PROV, + metadata=metadata, + ) + def _make_fragments(total_tokens: int, count: int = 4) -> list[ContextFragment]: """Create *count* fragments summing to *total_tokens*.""" @@ -31,11 +60,11 @@ def _make_fragments(total_tokens: int, count: int = 4) -> list[ContextFragment]: for i in range(count): tokens = base + (remainder if i == 0 else 0) frags.append( - ContextFragment( + _make_skel_fragment( fragment_id=f"frag-{i:03d}", content=f"content-{i}" * max(1, tokens // 10), token_count=tokens, - relevance=relevances[i % len(relevances)], + relevance_score=relevances[i % len(relevances)], source_decision_id=f"01HX{'A' * 22}{i}" if i < 3 else None, ) ) @@ -52,6 +81,7 @@ def _make_plan_id() -> str: @given("a skeleton compressor service") def step_create_service(context: Context) -> None: context.service = SkeletonCompressorService() + context.compressor_error = None # --- Fragment setup -------------------------------------------------------- @@ -65,11 +95,11 @@ def step_fragments_total(context: Context, total: int) -> None: @given("three fragments with equal relevance {rel:g}") def step_equal_relevance(context: Context, rel: float) -> None: context.fragments = [ - ContextFragment( + _make_skel_fragment( fragment_id=f"frag-{chr(ord('c') - i)}", content="x" * 50, token_count=100, - relevance=rel, + relevance_score=rel, ) for i in range(3) ] @@ -78,27 +108,33 @@ def step_equal_relevance(context: Context, rel: float) -> None: @given("fragments with relevances 0.9, 0.3, and 0.7") def step_varied_relevances(context: Context) -> None: context.fragments = [ - ContextFragment(fragment_id="f-1", content="a", token_count=100, relevance=0.9), - ContextFragment(fragment_id="f-2", content="b", token_count=100, relevance=0.3), - ContextFragment(fragment_id="f-3", content="c", token_count=100, relevance=0.7), + _make_skel_fragment( + fragment_id="f-1", content="a", token_count=100, relevance_score=0.9 + ), + _make_skel_fragment( + fragment_id="f-2", content="b", token_count=100, relevance_score=0.3 + ), + _make_skel_fragment( + fragment_id="f-3", content="c", token_count=100, relevance_score=0.7 + ), ] @given("fragments with known decision IDs") def step_known_ids(context: Context) -> None: context.fragments = [ - ContextFragment( + _make_skel_fragment( fragment_id="f-1", content="a", token_count=100, - relevance=0.9, + relevance_score=0.9, source_decision_id="01HXDECISION00000000000001", ), - ContextFragment( + _make_skel_fragment( fragment_id="f-2", content="b", token_count=100, - relevance=0.5, + relevance_score=0.5, source_decision_id="01HXDECISION00000000000002", ), ] @@ -112,25 +148,30 @@ def step_empty_frags(context: Context) -> None: @given("a single fragment with {tokens:d} tokens") def step_single_frag(context: Context, tokens: int) -> None: context.fragments = [ - ContextFragment( + _make_skel_fragment( fragment_id="only", content="x" * tokens, token_count=tokens, - relevance=0.8, + relevance_score=0.8, ), ] @given("a fragment with negative token count") def step_neg_tokens(context: Context) -> None: - context.fragments = [ - ContextFragment( - fragment_id="bad", - content="x", - token_count=-10, - relevance=0.5, - ), - ] + try: + context.fragments = [ + _make_skel_fragment( + fragment_id="bad", + content="x", + token_count=-10, + relevance_score=0.5, + ), + ] + except (ValueError, ValidationError) as exc: + # Model-level validation now catches this at construction time. + context.compressor_error = exc + context.fragments = [] @given("a fragment with empty fragment_id") @@ -138,23 +179,30 @@ def step_empty_id(context: Context) -> None: context.fragments = [ ContextFragment( fragment_id="", + uko_node="skeleton://empty-id", content="x", token_count=10, - relevance=0.5, + relevance_score=0.5, + provenance=_SKEL_PROV, ), ] @given("a fragment with relevance {rel:g}") def step_bad_relevance(context: Context, rel: float) -> None: - context.fragments = [ - ContextFragment( - fragment_id="bad", - content="x", - token_count=10, - relevance=rel, - ), - ] + try: + context.fragments = [ + _make_skel_fragment( + fragment_id="bad", + content="x", + token_count=10, + relevance_score=rel, + ), + ] + except (ValueError, ValidationError) as exc: + # Model-level validation now catches this at construction time. + context.compressor_error = exc + context.fragments = [] @given( @@ -174,6 +222,12 @@ def step_make_metadata(context: Context, ratio: float, orig: int, comp: int) -> @when("I compress with skeleton_ratio {ratio:g}") def step_compress_ratio(context: Context, ratio: float) -> None: + # If model-level validation already caught an error during fragment + # construction (Given step), propagate it as the compress error. + if getattr(context, "compressor_error", None) is not None: + context.error = context.compressor_error + context.result = None + return try: context.result = context.service.compress( context.fragments, skeleton_ratio=ratio @@ -205,8 +259,11 @@ def step_compress_non_fragment_item(context: Context) -> None: try: context.result = context.service.compress( [ - ContextFragment( - fragment_id="ok", content="x", token_count=10, relevance=0.5 + _make_skel_fragment( + fragment_id="ok", + content="x", + token_count=10, + relevance_score=0.5, ), "not-a-fragment", ], # type: ignore[list-item] @@ -279,8 +336,8 @@ def step_all_returned(context: Context) -> None: def step_top_one(context: Context) -> None: assert context.result is not None assert len(context.result.fragments) == 1 - top_relevance = max(f.relevance for f in context.fragments) - assert context.result.fragments[0].relevance == top_relevance + top_relevance = max(f.relevance_score for f in context.fragments) + assert context.result.fragments[0].relevance_score == top_relevance @then("compressed tokens should be at most {limit:d}") @@ -306,13 +363,13 @@ def step_ordered_by_id(context: Context) -> None: @then("the first fragment should have relevance {rel:g}") def step_first_relevance(context: Context, rel: float) -> None: assert context.result is not None - assert context.result.fragments[0].relevance == rel + assert context.result.fragments[0].relevance_score == rel @then("the last fragment should have relevance {rel:g}") def step_last_relevance(context: Context, rel: float) -> None: assert context.result is not None - assert context.result.fragments[-1].relevance == rel + assert context.result.fragments[-1].relevance_score == rel @then("metadata original_tokens should be {expected:d}") @@ -337,9 +394,9 @@ def step_compressed_at_most(context: Context, limit: int) -> None: def step_all_decision_ids(context: Context) -> None: assert context.result is not None expected_ids = { - f.source_decision_id + f.metadata["source_decision_id"] for f in context.fragments - if f.source_decision_id is not None + if "source_decision_id" in f.metadata } actual_ids = set(context.result.metadata.source_decision_ids) assert expected_ids == actual_ids @@ -417,3 +474,67 @@ def step_plan_cli_dict(context: Context) -> None: assert skel["ratio"] == context.skel_meta.ratio assert skel["original_tokens"] == context.skel_meta.original_tokens assert skel["compressed_tokens"] == context.skel_meta.compressed_tokens + + +# --- _validate_fragments edge-case steps -------------------------------- + + +@given("a context fragment constructed with negative token_count") +def step_frag_negative_token(context: Context) -> None: + frag = ContextFragment.model_construct( + fragment_id="neg-tc", + uko_node="skeleton://neg-tc", + content="x", + token_count=-5, + relevance_score=0.5, + provenance=_SKEL_PROV, + ) + context.invalid_fragments = [frag] + + +@given("a context fragment constructed with relevance_score {score}") +def step_frag_bad_relevance(context: Context, score: str) -> None: + frag = ContextFragment.model_construct( + fragment_id="bad-rel", + uko_node="skeleton://bad-rel", + content="x", + token_count=10, + relevance_score=float(score), + provenance=_SKEL_PROV, + ) + context.invalid_fragments = [frag] + + +@given("a context fragment constructed with empty fragment_id") +def step_frag_empty_id(context: Context) -> None: + frag = ContextFragment.model_construct( + fragment_id="", + uko_node="skeleton://empty", + content="x", + token_count=10, + relevance_score=0.5, + provenance=_SKEL_PROV, + ) + context.invalid_fragments = [frag] + + +@when("I validate the invalid fragments") +def step_validate_invalid(context: Context) -> None: + try: + SkeletonCompressorService._validate_fragments(context.invalid_fragments) + context.validation_error = None + except (TypeError, ValueError) as exc: + context.validation_error = exc + + +@then('the compressor should raise a ValueError mentioning "{text}"') +def step_check_validation_error(context: Context, text: str) -> None: + assert context.validation_error is not None, ( + "Expected a ValueError but none was raised" + ) + assert isinstance(context.validation_error, ValueError), ( + f"Expected ValueError, got {type(context.validation_error).__name__}" + ) + assert text in str(context.validation_error), ( + f"Expected '{text}' in error message: {context.validation_error}" + ) diff --git a/features/steps/unified_context_models_steps.py b/features/steps/unified_context_models_steps.py new file mode 100644 index 000000000..5e2a03128 --- /dev/null +++ b/features/steps/unified_context_models_steps.py @@ -0,0 +1,277 @@ +"""Step definitions for features/unified_context_models.feature. + +Tests isinstance compatibility and construction of the unified +ContextFragment model hierarchy. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.domain.models.acms.crp import ( + AssembledContext as CRPAssembledContext, +) +from cleveragents.domain.models.acms.crp import ( + ContextBudget as CRPContextBudget, +) +from cleveragents.domain.models.acms.crp import ( + ContextFragment as CRPContextFragment, +) +from cleveragents.domain.models.acms.crp import ( + FragmentProvenance as CRPFragmentProvenance, +) +from cleveragents.domain.models.core.context_fragment import ( + ContextBudget, + ContextFragment, + ContextPayload, + FragmentProvenance, +) + +# Default provenance for test fragments. +_DEFAULT_PROVENANCE = FragmentProvenance(resource_uri="test://unified") + + +# --------------------------------------------------------------------------- +# Given — Module availability +# --------------------------------------------------------------------------- + + +@given("the unified model modules are available") +def step_modules_available(context: Context) -> None: + pass + + +# --------------------------------------------------------------------------- +# Given — FragmentProvenance +# --------------------------------------------------------------------------- + + +@given('a core FragmentProvenance with resource_uri "{uri}"') +def step_core_provenance(context: Context, uri: str) -> None: + context.provenance = FragmentProvenance(resource_uri=uri) + + +@given('a core FragmentProvenance for strategy "{strategy}" with resource_uri "{uri}"') +def step_core_provenance_with_strategy( + context: Context, strategy: str, uri: str +) -> None: + context.provenance = FragmentProvenance(resource_uri=uri, strategy=strategy) + + +@given('a CRP FragmentProvenance with resource_uri "{uri}"') +def step_crp_provenance(context: Context, uri: str) -> None: + context.crp_provenance = CRPFragmentProvenance(resource_uri=uri) + + +# --------------------------------------------------------------------------- +# Given — ContextFragment +# --------------------------------------------------------------------------- + + +@given('a core ContextFragment with uko_node "{uko_node}" and content "{content}"') +def step_core_fragment(context: Context, uko_node: str, content: str) -> None: + context.unified_fragment = ContextFragment( + uko_node=uko_node, + content=content, + token_count=len(content), + provenance=_DEFAULT_PROVENANCE, + ) + + +@given("two core ContextFragments with identical fields") +def step_two_identical_fragments(context: Context) -> None: + fixed_time = datetime.now(UTC) + frag_a = ContextFragment( + fragment_id="FIXEDIDFOREQUALITYTEST12345", + uko_node="project://eq-test", + content="equal", + token_count=5, + provenance=_DEFAULT_PROVENANCE, + created_at=fixed_time, + ) + frag_b = ContextFragment( + fragment_id="FIXEDIDFOREQUALITYTEST12345", + uko_node="project://eq-test", + content="equal", + token_count=5, + provenance=_DEFAULT_PROVENANCE, + created_at=fixed_time, + ) + context.frag_a = frag_a + context.frag_b = frag_b + + +# --------------------------------------------------------------------------- +# Given — ContextBudget +# --------------------------------------------------------------------------- + + +@given("a core ContextBudget with max_tokens {max_tok:d} and reserved_tokens {res:d}") +def step_core_budget(context: Context, max_tok: int, res: int) -> None: + context.unified_budget = ContextBudget(max_tokens=max_tok, reserved_tokens=res) + + +# --------------------------------------------------------------------------- +# Given — ContextPayload +# --------------------------------------------------------------------------- + + +@given('a core ContextPayload with plan_id "{plan_id}"') +def step_core_payload(context: Context, plan_id: str) -> None: + context.unified_payload = ContextPayload(plan_id=plan_id) + + +# --------------------------------------------------------------------------- +# When — Mutation attempts +# --------------------------------------------------------------------------- + + +@when("I attempt to mutate the fragment uko_node") +def step_mutate_fragment(context: Context) -> None: + context.mutation_error = None + try: + context.unified_fragment.uko_node = "project://mutated" # type: ignore[misc] + except ValidationError as exc: + context.mutation_error = exc + + +@when("I attempt to mutate the budget max_tokens") +def step_mutate_budget(context: Context) -> None: + context.mutation_error = None + try: + context.unified_budget.max_tokens = 9999 # type: ignore[misc] + except ValidationError as exc: + context.mutation_error = exc + + +@when("I attempt to mutate the CRP provenance resource_uri") +def step_mutate_crp_provenance(context: Context) -> None: + context.mutation_error = None + try: + context.crp_provenance.resource_uri = "test://mutated" # type: ignore[misc] + except ValidationError as exc: + context.mutation_error = exc + + +@when("I create a core ContextBudget with reserved_tokens equal to max_tokens") +def step_create_budget_equal(context: Context) -> None: + context.strict_budget_error = None + try: + ContextBudget(max_tokens=100, reserved_tokens=100) + except ValidationError as exc: + context.strict_budget_error = exc + + +# --------------------------------------------------------------------------- +# Then — isinstance checks +# --------------------------------------------------------------------------- + + +@then("the provenance should be an instance of CRP FragmentProvenance") +def step_provenance_isinstance(context: Context) -> None: + assert isinstance(context.provenance, CRPFragmentProvenance), ( + f"Expected isinstance of CRP FragmentProvenance, got {type(context.provenance)}" + ) + + +@then("the fragment should be an instance of CRP ContextFragment") +def step_fragment_isinstance(context: Context) -> None: + assert isinstance(context.unified_fragment, CRPContextFragment), ( + f"Expected isinstance of CRP ContextFragment, " + f"got {type(context.unified_fragment)}" + ) + + +@then("the budget should be an instance of CRP ContextBudget") +def step_budget_isinstance(context: Context) -> None: + assert isinstance(context.unified_budget, CRPContextBudget), ( + f"Expected isinstance of CRP ContextBudget, got {type(context.unified_budget)}" + ) + + +@then("the payload should be an instance of CRP AssembledContext") +def step_payload_isinstance(context: Context) -> None: + assert isinstance(context.unified_payload, CRPAssembledContext), ( + f"Expected isinstance of CRP AssembledContext, " + f"got {type(context.unified_payload)}" + ) + + +# --------------------------------------------------------------------------- +# Then — Field value assertions +# --------------------------------------------------------------------------- + + +@then('the provenance resource_type should be "{value}"') +def step_provenance_resource_type(context: Context, value: str) -> None: + assert context.provenance.resource_type == value + + +@then('the provenance strategy should be "{value}"') +def step_provenance_strategy(context: Context, value: str) -> None: + assert context.provenance.strategy == value + + +@then('the unified fragment tier should be "{tier}"') +def step_fragment_tier(context: Context, tier: str) -> None: + assert context.unified_fragment.tier == tier + + +@then("the unified fragment fragment_id should be non-empty") +def step_fragment_id_nonempty(context: Context) -> None: + assert context.unified_fragment.fragment_id + assert len(context.unified_fragment.fragment_id) > 0 + + +@then("the unified fragment fragment_id should be {length:d} characters") +def step_fragment_id_length(context: Context, length: int) -> None: + fid = context.unified_fragment.fragment_id + assert len(fid) == length, ( + f"Expected fragment_id of {length} chars, got {len(fid)}: {fid!r}" + ) + + +@then("the unified fragment created_at should be a recent datetime") +def step_fragment_created_at_recent(context: Context) -> None: + created = context.unified_fragment.created_at + assert isinstance(created, datetime) + now = datetime.now(UTC) + assert now - created < timedelta(seconds=60) + + +@then("the unified budget available_tokens should be {tokens:d}") +def step_budget_available(context: Context, tokens: int) -> None: + assert context.unified_budget.available_tokens == tokens + + +# --------------------------------------------------------------------------- +# Then — Mutation error assertions +# --------------------------------------------------------------------------- + + +@then("a frozen mutation error should be raised") +def step_frozen_mutation_error(context: Context) -> None: + assert context.mutation_error is not None, ( + "Expected a frozen mutation error but none was raised" + ) + + +# --------------------------------------------------------------------------- +# Then — Equality assertions +# --------------------------------------------------------------------------- + + +@then("the two fragments should be equal") +def step_fragments_equal(context: Context) -> None: + assert context.frag_a == context.frag_b + + +@then("a validation error should be raised for strict budget") +def step_strict_budget_error(context: Context) -> None: + assert context.strict_budget_error is not None, ( + "Expected a validation error for reserved == max but none was raised" + ) diff --git a/features/unified_context_models.feature b/features/unified_context_models.feature new file mode 100644 index 000000000..c1fd4ff6a --- /dev/null +++ b/features/unified_context_models.feature @@ -0,0 +1,67 @@ +Feature: Unified context fragment model hierarchies + The core ContextFragment, FragmentProvenance, ContextBudget, and + ContextPayload types extend their CRP base counterparts, ensuring + isinstance compatibility across the model hierarchy. + + Background: + Given the unified model modules are available + + # --------------------------------------------------------------- + # isinstance compatibility + # --------------------------------------------------------------- + + Scenario: Core FragmentProvenance is instance of CRP FragmentProvenance + Given a core FragmentProvenance with resource_uri "project://test" + Then the provenance should be an instance of CRP FragmentProvenance + And the provenance resource_type should be "unknown" + And the provenance strategy should be empty + + Scenario: Core FragmentProvenance inherits CRP strategy field + Given a core FragmentProvenance for strategy "tier_retrieval" with resource_uri "project://test" + Then the provenance strategy should be "tier_retrieval" + And the provenance resource_type should be "unknown" + + Scenario: Core ContextFragment is instance of CRP ContextFragment + Given a core ContextFragment with uko_node "project://app/main.py" and content "hello" + Then the fragment should be an instance of CRP ContextFragment + And the unified fragment tier should be "warm" + And the unified fragment fragment_id should be non-empty + + Scenario: Core ContextFragment has ULID fragment_id by default + Given a core ContextFragment with uko_node "project://test" and content "test content" + Then the unified fragment fragment_id should be 26 characters + + Scenario: Core ContextFragment created_at is auto-populated + Given a core ContextFragment with uko_node "project://test" and content "test content" + Then the unified fragment created_at should be a recent datetime + + # --------------------------------------------------------------- + # Immutability (frozen) + # --------------------------------------------------------------- + + Scenario: Core ContextFragment is frozen + Given a core ContextFragment with uko_node "project://frozen" and content "frozen" + When I attempt to mutate the fragment uko_node + Then a frozen mutation error should be raised + + Scenario: Core ContextBudget is frozen + Given a core ContextBudget with max_tokens 4096 and reserved_tokens 512 + When I attempt to mutate the budget max_tokens + Then a frozen mutation error should be raised + + Scenario: CRP FragmentProvenance is frozen + Given a CRP FragmentProvenance with resource_uri "test://crp" + When I attempt to mutate the CRP provenance resource_uri + Then a frozen mutation error should be raised + + # --------------------------------------------------------------- + # Equality across hierarchy + # --------------------------------------------------------------- + + Scenario: Two core fragments with same data are equal + Given two core ContextFragments with identical fields + Then the two fragments should be equal + + Scenario: Core ContextBudget strict validation rejects equal reserved and max + When I create a core ContextBudget with reserved_tokens equal to max_tokens + Then a validation error should be raised for strict budget diff --git a/robot/helper_skeleton_compressor.py b/robot/helper_skeleton_compressor.py index ab68fc5c7..c26a27708 100644 --- a/robot/helper_skeleton_compressor.py +++ b/robot/helper_skeleton_compressor.py @@ -18,33 +18,61 @@ if _SRC not in sys.path: sys.path.insert(0, _SRC) from cleveragents.application.services.skeleton_compressor import ( # noqa: E402 - ContextFragment, SkeletonCompressorService, ) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextFragment, + FragmentProvenance, +) + +# Default provenance for skeleton test fragments. +_SKEL_PROV = FragmentProvenance(resource_uri="skeleton://robot-test") + + +def _make_skel_fragment( + fragment_id: str, + content: str, + token_count: int, + relevance_score: float, + source_decision_id: str | None = None, +) -> ContextFragment: + """Create a ContextFragment suitable for skeleton compression tests.""" + metadata: dict[str, str] = {} + if source_decision_id is not None: + metadata["source_decision_id"] = source_decision_id + return ContextFragment( + fragment_id=fragment_id, + uko_node=f"skeleton://{fragment_id}", + content=content, + token_count=token_count, + relevance_score=relevance_score, + provenance=_SKEL_PROV, + metadata=metadata, + ) def _sample_fragments() -> list[ContextFragment]: """Build a repeatable set of sample fragments.""" return [ - ContextFragment( + _make_skel_fragment( fragment_id="frag-001", content="High relevance content " * 20, token_count=400, - relevance=0.9, + relevance_score=0.9, source_decision_id="01HXDECISION00000000000001", ), - ContextFragment( + _make_skel_fragment( fragment_id="frag-002", content="Medium relevance content " * 15, token_count=300, - relevance=0.6, + relevance_score=0.6, source_decision_id="01HXDECISION00000000000002", ), - ContextFragment( + _make_skel_fragment( fragment_id="frag-003", content="Low relevance content " * 15, token_count=300, - relevance=0.3, + relevance_score=0.3, source_decision_id="01HXDECISION00000000000003", ), ] @@ -114,11 +142,11 @@ def cmd_stable_ordering() -> None: """Verify fragments come out in deterministic order.""" svc = SkeletonCompressorService() frags = [ - ContextFragment( + _make_skel_fragment( fragment_id=f"frag-{chr(ord('c') - i)}", content="x", token_count=10, - relevance=0.5, + relevance_score=0.5, ) for i in range(3) ] diff --git a/robot/helper_unified_context_models.py b/robot/helper_unified_context_models.py new file mode 100644 index 000000000..db45c219b --- /dev/null +++ b/robot/helper_unified_context_models.py @@ -0,0 +1,178 @@ +"""Robot Framework helper for unified context model integration tests. + +Verifies isinstance compatibility across the CRP/core model hierarchy +and basic construction/immutability contracts. + +Usage: + python robot/helper_unified_context_models.py isinstance-check + python robot/helper_unified_context_models.py construction-check + python robot/helper_unified_context_models.py immutability-check +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + AssembledContext as CRPAssembledContext, +) +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + ContextBudget as CRPContextBudget, +) +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + ContextFragment as CRPContextFragment, +) +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + FragmentProvenance as CRPFragmentProvenance, +) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextBudget, + ContextFragment, + ContextPayload, + FragmentProvenance, +) + +_DEFAULT_PROV = FragmentProvenance(resource_uri="test://robot-unified") + + +def _cmd_isinstance_check() -> int: + """Verify isinstance compatibility for all four model types.""" + prov = FragmentProvenance(resource_uri="test://robot") + if not isinstance(prov, CRPFragmentProvenance): + print("FAIL: FragmentProvenance is not instance of CRP type") + return 1 + + frag = ContextFragment( + uko_node="test://robot", + content="hello", + token_count=5, + provenance=prov, + ) + if not isinstance(frag, CRPContextFragment): + print("FAIL: ContextFragment is not instance of CRP type") + return 1 + + budget = ContextBudget(max_tokens=4096, reserved_tokens=512) + if not isinstance(budget, CRPContextBudget): + print("FAIL: ContextBudget is not instance of CRP type") + return 1 + + payload = ContextPayload(plan_id="01JQTESTPN00000000000000AA") + if not isinstance(payload, CRPAssembledContext): + print("FAIL: ContextPayload is not instance of CRP AssembledContext") + return 1 + + print("unified-isinstance-ok") + return 0 + + +def _cmd_construction_check() -> int: + """Verify construction with inherited and extended fields.""" + prov = FragmentProvenance( + resource_uri="test://construct", + strategy="test_strategy", + resource_type="git-checkout", + ) + if prov.strategy != "test_strategy": + print(f"FAIL: expected strategy='test_strategy', got {prov.strategy!r}") + return 1 + if prov.resource_type != "git-checkout": + print( + f"FAIL: expected resource_type='git-checkout', got {prov.resource_type!r}" + ) + return 1 + + frag = ContextFragment( + uko_node="project://test", + content="constructed fragment", + token_count=20, + tier="hot", + provenance=prov, + ) + if frag.tier != "hot": + print(f"FAIL: expected tier='hot', got {frag.tier!r}") + return 1 + if not frag.fragment_id: + print("FAIL: fragment_id is empty") + return 1 + if len(frag.fragment_id) != 26: + print(f"FAIL: expected ULID (26 chars), got {len(frag.fragment_id)}") + return 1 + + budget = ContextBudget(max_tokens=8192, reserved_tokens=1024) + if budget.available_tokens != 7168: + print(f"FAIL: expected available=7168, got {budget.available_tokens}") + return 1 + + print("unified-construction-ok") + return 0 + + +def _cmd_immutability_check() -> int: + """Verify that frozen models reject mutation.""" + frag = ContextFragment( + uko_node="project://immut", + content="test", + token_count=4, + provenance=_DEFAULT_PROV, + ) + try: + frag.uko_node = "project://mutated" # type: ignore[misc] + print("FAIL: expected frozen error for ContextFragment mutation") + return 1 + except Exception: + pass + + budget = ContextBudget(max_tokens=4096, reserved_tokens=512) + try: + budget.max_tokens = 9999 # type: ignore[misc] + print("FAIL: expected frozen error for ContextBudget mutation") + return 1 + except Exception: + pass + + prov = CRPFragmentProvenance(resource_uri="test://crp") + try: + prov.resource_uri = "test://mutated" # type: ignore[misc] + print("FAIL: expected frozen error for CRP FragmentProvenance mutation") + return 1 + except Exception: + pass + + print("unified-immutability-ok") + return 0 + + +_COMMANDS: dict[str, Callable[[], int]] = { + "isinstance-check": _cmd_isinstance_check, + "construction-check": _cmd_construction_check, + "immutability-check": _cmd_immutability_check, +} + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print( + "Usage: helper_unified_context_models.py " + "" + ) + return 1 + + command = sys.argv[1] + handler = _COMMANDS.get(command) + if handler is None: + print(f"Unknown command: {command}") + return 1 + + return handler() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/unified_context_models.robot b/robot/unified_context_models.robot new file mode 100644 index 000000000..333913d1c --- /dev/null +++ b/robot/unified_context_models.robot @@ -0,0 +1,33 @@ +*** Settings *** +Documentation Integration tests for unified CRP/core context model hierarchy +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_unified_context_models.py + +*** Test Cases *** +Core Types Are Instances Of CRP Base Types + [Documentation] Verify that all four core types pass isinstance checks against CRP bases + ${result}= Run Process ${PYTHON} ${HELPER} isinstance-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} unified-isinstance-ok + +Unified Models Construct With Inherited And Extended Fields + [Documentation] Verify construction populates both CRP and core-specific fields + ${result}= Run Process ${PYTHON} ${HELPER} construction-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} unified-construction-ok + +Frozen Models Reject Mutation + [Documentation] Verify frozen configuration is inherited and enforced + ${result}= Run Process ${PYTHON} ${HELPER} immutability-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} unified-immutability-ok diff --git a/src/cleveragents/application/services/__init__.py b/src/cleveragents/application/services/__init__.py index 310f667ee..352bda17b 100644 --- a/src/cleveragents/application/services/__init__.py +++ b/src/cleveragents/application/services/__init__.py @@ -106,7 +106,6 @@ from cleveragents.application.services.session_service import ( ) from cleveragents.application.services.skeleton_compressor import ( CompressionResult, - ContextFragment, SkeletonCompressorService, ) from cleveragents.application.services.skill_registry_service import ( diff --git a/src/cleveragents/application/services/skeleton_compressor.py b/src/cleveragents/application/services/skeleton_compressor.py index 563b34eeb..8867e4873 100644 --- a/src/cleveragents/application/services/skeleton_compressor.py +++ b/src/cleveragents/application/services/skeleton_compressor.py @@ -9,7 +9,7 @@ by ``skeleton_ratio``: - **1.0** — maximum compression; only the single highest-relevance fragment is kept (with minimal content). -Fragments are sorted by ``relevance`` score in **descending** order +Fragments are sorted by ``relevance_score`` in **descending** order (highest first). A stable secondary sort on ``fragment_id`` ensures deterministic output for equal-relevance fragments. @@ -20,6 +20,7 @@ from __future__ import annotations from dataclasses import dataclass +from cleveragents.domain.models.core.context_fragment import ContextFragment from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata # --------------------------------------------------------------------------- @@ -27,27 +28,6 @@ from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata # --------------------------------------------------------------------------- -@dataclass(frozen=True, slots=True) -class ContextFragment: - """A single fragment of context to be compressed. - - Attributes: - fragment_id: Stable identifier for this fragment. - content: The textual content of the fragment. - token_count: Number of tokens in ``content``. - relevance: A score in [0.0, 1.0] indicating how relevant - this fragment is (higher = more relevant). - source_decision_id: Optional decision ULID that produced - this fragment. - """ - - fragment_id: str - content: str - token_count: int - relevance: float - source_decision_id: str | None = None - - @dataclass(frozen=True, slots=True) class CompressionResult: """Output of a skeleton compression pass. @@ -86,7 +66,7 @@ class SkeletonCompressorService: Args: fragments: Context fragments to compress. Each must - have ``token_count >= 0`` and ``relevance`` in + have ``token_count >= 0`` and ``relevance_score`` in ``[0.0, 1.0]``. skeleton_ratio: Compression ratio in ``[0.0, 1.0]``. ``None`` falls back to ``DEFAULT_SKELETON_RATIO``. @@ -126,7 +106,7 @@ class SkeletonCompressorService: # -- stable sort: relevance desc, fragment_id asc ----------------- sorted_fragments = sorted( fragments, - key=lambda f: (-f.relevance, f.fragment_id), + key=lambda f: (-f.relevance_score, f.fragment_id), ) # -- select fragments within budget ------------------------------- @@ -135,7 +115,9 @@ class SkeletonCompressorService: compressed_tokens = sum(f.token_count for f in kept) source_ids = tuple( - f.source_decision_id for f in kept if f.source_decision_id is not None + f.metadata["source_decision_id"] + for f in kept + if "source_decision_id" in f.metadata ) metadata = SkeletonMetadata( @@ -172,10 +154,10 @@ class SkeletonCompressorService: raise ValueError( f"fragments[{idx}].token_count must be >= 0, got {frag.token_count}" ) - if frag.relevance < 0.0 or frag.relevance > 1.0: + if frag.relevance_score < 0.0 or frag.relevance_score > 1.0: raise ValueError( - f"fragments[{idx}].relevance must be in [0.0, 1.0], " - f"got {frag.relevance}" + f"fragments[{idx}].relevance_score must be in [0.0, 1.0], " + f"got {frag.relevance_score}" ) if not frag.fragment_id: raise ValueError(f"fragments[{idx}].fragment_id must be non-empty") diff --git a/src/cleveragents/cli/commands/project_context.py b/src/cleveragents/cli/commands/project_context.py index 932397cf0..051556a99 100644 --- a/src/cleveragents/cli/commands/project_context.py +++ b/src/cleveragents/cli/commands/project_context.py @@ -362,10 +362,10 @@ def _simulate_context_assembly( ) return AssembledContext( - fragments=fragments, + fragments=tuple(fragments), total_tokens=total_tokens, budget_used=min(budget_used, 1.0), - strategies_used=strategies_used, + strategies_used=tuple(strategies_used), context_hash=context_hash, preamble=( f"Simulated context for {project_name}" diff --git a/src/cleveragents/domain/models/acms/crp.py b/src/cleveragents/domain/models/acms/crp.py index 8be082926..fe603baf5 100644 --- a/src/cleveragents/domain/models/acms/crp.py +++ b/src/cleveragents/domain/models/acms/crp.py @@ -158,7 +158,7 @@ class DetailLevelMap(BaseModel): # --------------------------------------------------------------------------- -class FragmentProvenance(BaseModel): +class FragmentProvenance(BaseModel, frozen=True): """Provenance trace for a context fragment. Links a fragment back to the originating resource and location @@ -181,7 +181,6 @@ class FragmentProvenance(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) @@ -190,7 +189,7 @@ class FragmentProvenance(BaseModel): # --------------------------------------------------------------------------- -class ContextBudget(BaseModel): +class ContextBudget(BaseModel, frozen=True): """Token budget for context assembly. Tracks the maximum token budget and any reserved portion, providing @@ -227,7 +226,6 @@ class ContextBudget(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) @@ -236,7 +234,7 @@ class ContextBudget(BaseModel): # --------------------------------------------------------------------------- -class ContextFragment(BaseModel): +class ContextFragment(BaseModel, frozen=True): """A single piece of context assembled by a strategy. The atomic unit of context returned by strategies and consumed by @@ -281,7 +279,6 @@ class ContextFragment(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) @@ -398,7 +395,7 @@ class ContextRequest(BaseModel): # --------------------------------------------------------------------------- -class AssembledContext(BaseModel): +class AssembledContext(BaseModel, frozen=True): """The fused, budget-respecting context payload. Output of a context assembly cycle -- the final payload delivered @@ -406,8 +403,8 @@ class AssembledContext(BaseModel): scoring, budget packing, and ordering. """ - fragments: list[ContextFragment] = Field( - default_factory=list, + fragments: tuple[ContextFragment, ...] = Field( + default=(), description="Ordered context fragments", ) total_tokens: int = Field( @@ -421,8 +418,8 @@ class AssembledContext(BaseModel): le=1.0, description="Fraction of budget consumed (0.0-1.0)", ) - strategies_used: list[str] = Field( - default_factory=list, + strategies_used: tuple[str, ...] = Field( + default=(), description="Which strategies contributed", ) context_hash: str = Field( @@ -441,5 +438,4 @@ class AssembledContext(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) diff --git a/src/cleveragents/domain/models/core/context_fragment.py b/src/cleveragents/domain/models/core/context_fragment.py index ab208188c..b3987d1f1 100644 --- a/src/cleveragents/domain/models/core/context_fragment.py +++ b/src/cleveragents/domain/models/core/context_fragment.py @@ -3,7 +3,13 @@ Defines the core value objects for the Adaptive Context Management System (ACMS) context assembly pipeline: ``ContextFragment``, ``FragmentProvenance``, ``ContextBudget``, and ``ContextPayload``. All models are frozen Pydantic v2 -value objects. +value objects that **extend** the mutable CRP base types defined in +:mod:`cleveragents.domain.models.acms.crp`. + +The inheritance hierarchy ensures that every core ``ContextFragment`` is also +an ``isinstance`` of the CRP ``ContextFragment``, eliminating the previous +duplication and enabling strategies written against either interface to +interoperate seamlessly. Based on ``docs/specification.md`` ~line 25075 (ContextFragment) and ~line 25092 (AssembledContext). @@ -19,6 +25,18 @@ from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator from ulid import ULID +from cleveragents.domain.models.acms.crp import ( + AssembledContext as CRPAssembledContext, +) +from cleveragents.domain.models.acms.crp import ( + ContextBudget as CRPContextBudget, +) +from cleveragents.domain.models.acms.crp import ( + ContextFragment as CRPContextFragment, +) +from cleveragents.domain.models.acms.crp import ( + FragmentProvenance as CRPFragmentProvenance, +) from cleveragents.domain.models.core.plan import ULID_PATTERN # Maximum content length (characters). Enforced to prevent unbounded memory @@ -29,24 +47,16 @@ MAX_CONTENT_LENGTH: int = 1_000_000 # ~1 MB of text MAX_METADATA_ENTRIES: int = 64 -class FragmentProvenance(BaseModel, frozen=True): +class FragmentProvenance(CRPFragmentProvenance, frozen=True): """Provenance trace for a context fragment. - Records the originating resource and location so that fragments can be - traced back to their source for auditability and debugging. + Extends the CRP ``FragmentProvenance`` with an additional + ``resource_type`` field for richer auditability. Frozen so that + provenance records are immutable value objects. Based on ``docs/specification.md`` ~line 25088. """ - resource_uri: str = Field( - ..., - min_length=1, - description="URI of the originating resource (e.g. 'project://myapp/src/main.py')", - ) - location: str = Field( - default="", - description="Location within the resource (e.g. line range, section name)", - ) resource_type: str = Field( default="unknown", description=( @@ -55,20 +65,18 @@ class FragmentProvenance(BaseModel, frozen=True): ) -class ContextFragment(BaseModel, frozen=True): +class ContextFragment(CRPContextFragment, frozen=True): """A single piece of context assembled by the ACMS pipeline. + Extends the CRP ``ContextFragment`` with ``fragment_id``, ``tier``, + ``created_at``, and stricter validation. Frozen so that fragments + are immutable value objects. + Based on ``docs/specification.md`` ~line 25081. """ fragment_id: str = Field(default_factory=lambda: str(ULID())) - uko_node: str = Field( - ..., - min_length=1, - description="UKO URI of the source node", - ) - content: str = Field( ..., max_length=MAX_CONTENT_LENGTH, @@ -84,12 +92,6 @@ class ContextFragment(BaseModel, frozen=True): ), ) - token_count: int = Field( - ..., - ge=0, - description="Actual token count of content", - ) - relevance_score: float = Field( ge=0.0, le=1.0, @@ -132,12 +134,13 @@ class ContextFragment(BaseModel, frozen=True): return dict(v) -class ContextBudget(BaseModel, frozen=True): +class ContextBudget(CRPContextBudget, frozen=True): """Token budget for context assembly. - ``reserved_tokens`` must be strictly less than ``max_tokens``. The - effective minimum for ``max_tokens`` is therefore - ``reserved_tokens + 1``. + Extends the CRP ``ContextBudget`` with stricter defaults and + validation: ``reserved_tokens`` must be strictly less than + ``max_tokens``. The effective minimum for ``max_tokens`` is + therefore ``reserved_tokens + 1``. """ max_tokens: int = Field(ge=1, default=4096) @@ -153,15 +156,13 @@ class ContextBudget(BaseModel, frozen=True): raise ValueError(msg) return self - @property - def available_tokens(self) -> int: - """Return tokens available after reserving system-prompt space.""" - return self.max_tokens - self.reserved_tokens - -class ContextPayload(BaseModel, frozen=True): +class ContextPayload(CRPAssembledContext, frozen=True): """Assembled context payload ready for actor consumption. + Extends the CRP ``AssembledContext`` with ``payload_id``, ``plan_id``, + ``budget``, ``assembled_at``, and immutable tuple-based collections. + Corresponds to the spec's ``AssembledContext`` (~line 25098). Includes ``budget_used`` fraction, ``strategies_used`` list, ``context_hash`` for snapshot integrity, optional ``preamble``, and ``provenance_map``.