agentevolve-optimizer 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_evolve/__init__.py +722 -0
- agent_evolve/agentic.py +2800 -0
- agent_evolve/api.py +767 -0
- agent_evolve/application/__init__.py +1580 -0
- agent_evolve/application/action_allocation.py +744 -0
- agent_evolve/application/action_allocation_frame.py +347 -0
- agent_evolve/application/action_allocation_frame_commit.py +185 -0
- agent_evolve/application/action_allocation_frame_commit_v3.py +184 -0
- agent_evolve/application/action_allocation_frame_v3.py +338 -0
- agent_evolve/application/action_archive_value.py +497 -0
- agent_evolve/application/action_evidence_consistency.py +455 -0
- agent_evolve/application/action_forecast_partitioning.py +1471 -0
- agent_evolve/application/action_metric_projection.py +211 -0
- agent_evolve/application/action_role_value.py +680 -0
- agent_evolve/application/action_score_authorities.py +363 -0
- agent_evolve/application/action_structural_signature.py +116 -0
- agent_evolve/application/action_target_realization.py +402 -0
- agent_evolve/application/agentic_evolution.py +7734 -0
- agent_evolve/application/agentic_portfolio_residual_expert.py +835 -0
- agent_evolve/application/anchor_residual_identification.py +463 -0
- agent_evolve/application/archive_conditioned_action_target.py +208 -0
- agent_evolve/application/artifact_journal.py +246 -0
- agent_evolve/application/artifact_replay.py +347 -0
- agent_evolve/application/budgeted_optimizer.py +1828 -0
- agent_evolve/application/calibrated_campaign.py +485 -0
- agent_evolve/application/calibrated_current_prefix_forecast_opportunity.py +322 -0
- agent_evolve/application/calibrated_positive_gain_opportunity.py +1581 -0
- agent_evolve/application/campaign_capacity_recourse.py +254 -0
- agent_evolve/application/campaign_contextual_outcomes.py +119 -0
- agent_evolve/application/campaign_diagnostic_blocks.py +930 -0
- agent_evolve/application/campaign_evidence_registry.py +262 -0
- agent_evolve/application/campaign_execution.py +2537 -0
- agent_evolve/application/campaign_generation_audit.py +942 -0
- agent_evolve/application/campaign_learning.py +1812 -0
- agent_evolve/application/campaign_learning_runtime.py +1977 -0
- agent_evolve/application/campaign_search_phase.py +227 -0
- agent_evolve/application/campaign_selector_context_extension.py +220 -0
- agent_evolve/application/campaign_variation_envelope.py +649 -0
- agent_evolve/application/campaign_variation_trace.py +451 -0
- agent_evolve/application/candidate_archive_consequence.py +128 -0
- agent_evolve/application/causal_opportunity_portfolio_gate.py +385 -0
- agent_evolve/application/composite_outcome_updater.py +145 -0
- agent_evolve/application/composition_portfolio_selection.py +363 -0
- agent_evolve/application/concurrent_stage.py +144 -0
- agent_evolve/application/contextual_action_allocation.py +181 -0
- agent_evolve/application/contextual_campaign_outcomes.py +267 -0
- agent_evolve/application/contextual_campaign_planning.py +1366 -0
- agent_evolve/application/contextual_delayed_credit.py +651 -0
- agent_evolve/application/contextual_search_controller.py +2374 -0
- agent_evolve/application/current_prefix_forecast_opportunity.py +714 -0
- agent_evolve/application/decision_metric_projection.py +112 -0
- agent_evolve/application/derived_action_semantics.py +129 -0
- agent_evolve/application/detailed_evaluation.py +449 -0
- agent_evolve/application/earned_lineage.py +1011 -0
- agent_evolve/application/effective_choice_audit.py +484 -0
- agent_evolve/application/empirical_consequence_calibration.py +908 -0
- agent_evolve/application/evaluation_accounting.py +325 -0
- agent_evolve/application/evaluation_cache.py +199 -0
- agent_evolve/application/evaluation_escrow.py +547 -0
- agent_evolve/application/evaluation_recourse.py +253 -0
- agent_evolve/application/event_recorder.py +151 -0
- agent_evolve/application/evolution_campaign.py +1840 -0
- agent_evolve/application/executable_hypothesis.py +323 -0
- agent_evolve/application/factorial_branch_pilot.py +772 -0
- agent_evolve/application/finite_acquisition_capacity_recourse.py +672 -0
- agent_evolve/application/finite_acquisition_residual_expert.py +373 -0
- agent_evolve/application/finite_acquisition_variation_envelope.py +802 -0
- agent_evolve/application/finite_action_hypothesis_semantics.py +446 -0
- agent_evolve/application/finite_action_selection.py +188 -0
- agent_evolve/application/finite_action_set.py +306 -0
- agent_evolve/application/finite_action_transition.py +537 -0
- agent_evolve/application/finite_variation_eligibility.py +296 -0
- agent_evolve/application/forecast_geometry_portfolio.py +799 -0
- agent_evolve/application/forecast_opportunity_shadow_calibration.py +316 -0
- agent_evolve/application/front_proximity_admission.py +311 -0
- agent_evolve/application/front_proximity_parent_basis.py +458 -0
- agent_evolve/application/frozen_hurdle_score.py +659 -0
- agent_evolve/application/g3_causal_screen.py +2257 -0
- agent_evolve/application/g3_causal_validation.py +1046 -0
- agent_evolve/application/g3_postseal_curation.py +818 -0
- agent_evolve/application/gated_agentic_generator.py +205 -0
- agent_evolve/application/generation_feedback.py +293 -0
- agent_evolve/application/generative_proposal_journal.py +185 -0
- agent_evolve/application/geometry_conditional_elasticity.py +453 -0
- agent_evolve/application/global_wave_action_allocation.py +1151 -0
- agent_evolve/application/head_mass_conditional_seat.py +268 -0
- agent_evolve/application/identifiable_reflection_evidence.py +1147 -0
- agent_evolve/application/identifiable_reflection_learning.py +395 -0
- agent_evolve/application/identifiable_reflection_request.py +364 -0
- agent_evolve/application/in_memory_residual_archive.py +341 -0
- agent_evolve/application/insight_memory.py +1804 -0
- agent_evolve/application/live_runtime_manifest.py +758 -0
- agent_evolve/application/llm_task_queue.py +769 -0
- agent_evolve/application/matched_finite_action_block.py +409 -0
- agent_evolve/application/materialized_action_broker.py +2328 -0
- agent_evolve/application/materialized_action_constraints.py +83 -0
- agent_evolve/application/materialized_variation.py +211 -0
- agent_evolve/application/multi_option_evolution.py +1536 -0
- agent_evolve/application/outcome_adaptive_action_racing.py +2827 -0
- agent_evolve/application/outcome_adaptive_residual_campaign_runtime.py +580 -0
- agent_evolve/application/outcome_adaptive_residual_portfolio_evolution.py +3671 -0
- agent_evolve/application/outcome_conditioned_portfolio_selection.py +1374 -0
- agent_evolve/application/outcome_relation.py +193 -0
- agent_evolve/application/paired_allocation_comparison.py +241 -0
- agent_evolve/application/paired_block_schedule.py +127 -0
- agent_evolve/application/parent_measurement.py +226 -0
- agent_evolve/application/pareto_archive.py +811 -0
- agent_evolve/application/portfolio_campaign_runtime.py +4739 -0
- agent_evolve/application/portfolio_evolution.py +2950 -0
- agent_evolve/application/portfolio_hypothesis_observations.py +814 -0
- agent_evolve/application/portfolio_memory_attribution.py +581 -0
- agent_evolve/application/portfolio_memory_dose.py +788 -0
- agent_evolve/application/portfolio_memory_matched_control.py +938 -0
- agent_evolve/application/portfolio_memory_transfer.py +297 -0
- agent_evolve/application/portfolio_optimization_memory.py +363 -0
- agent_evolve/application/portfolio_outcome_feedback.py +1613 -0
- agent_evolve/application/portfolio_projection.py +335 -0
- agent_evolve/application/portfolio_recombination.py +2032 -0
- agent_evolve/application/post_evolution_reflection.py +834 -0
- agent_evolve/application/postcommit_rank_authority.py +245 -0
- agent_evolve/application/precommitted_portfolio_racing.py +2762 -0
- agent_evolve/application/prequential_archive_opportunity_calibration.py +1154 -0
- agent_evolve/application/prequential_residual_exploration.py +343 -0
- agent_evolve/application/prequential_score_portfolio.py +954 -0
- agent_evolve/application/projections.py +292 -0
- agent_evolve/application/protected_action_committee.py +1027 -0
- agent_evolve/application/protected_branch_pilot.py +376 -0
- agent_evolve/application/protected_current_prefix_forecast_opportunity.py +552 -0
- agent_evolve/application/provider_replay.py +910 -0
- agent_evolve/application/rank_balanced_causal_pilot.py +1372 -0
- agent_evolve/application/recombination_residual_expert.py +403 -0
- agent_evolve/application/reflection_workflow.py +571 -0
- agent_evolve/application/region_conditional_credit.py +911 -0
- agent_evolve/application/residual_campaign_runtime.py +531 -0
- agent_evolve/application/residual_headroom_campaign_runtime.py +459 -0
- agent_evolve/application/residual_headroom_ledger.py +1544 -0
- agent_evolve/application/residual_learning_transaction.py +396 -0
- agent_evolve/application/residual_portfolio_evolution.py +1228 -0
- agent_evolve/application/residual_reachability.py +749 -0
- agent_evolve/application/residual_stage_credit.py +499 -0
- agent_evolve/application/same_prefix_paired_audit.py +1580 -0
- agent_evolve/application/semantic_coverage_score_portfolio.py +838 -0
- agent_evolve/application/sequential_lineage_allocation.py +1017 -0
- agent_evolve/application/sequential_market_replay.py +1395 -0
- agent_evolve/application/sequential_residual_campaign_runtime.py +305 -0
- agent_evolve/application/sequential_residual_portfolio_evolution.py +940 -0
- agent_evolve/application/single_score_action_allocation.py +299 -0
- agent_evolve/application/source_exposure_allocation.py +906 -0
- agent_evolve/application/staged_memory.py +210 -0
- agent_evolve/application/stratified_cold_start_allocation.py +732 -0
- agent_evolve/application/support_guarded_hurdle_score.py +549 -0
- agent_evolve/application/target_conditioned_action_forecast.py +595 -0
- agent_evolve/application/target_conditioned_campaign.py +566 -0
- agent_evolve/application/treatment_assignment.py +201 -0
- agent_evolve/application/trusted_objective_evidence.py +217 -0
- agent_evolve/application/two_stage_action_evolution.py +1131 -0
- agent_evolve/application/v8lite_allocation_policy.py +1083 -0
- agent_evolve/application/v9_candidate_policy.py +1303 -0
- agent_evolve/bootstrap.py +108 -0
- agent_evolve/campaign_presets.py +517 -0
- agent_evolve/campaign_profiles.py +452 -0
- agent_evolve/campaign_variation_topology.py +288 -0
- agent_evolve/campaign_workload.py +950 -0
- agent_evolve/cli.py +797 -0
- agent_evolve/contract.py +241 -0
- agent_evolve/core/__init__.py +91 -0
- agent_evolve/core/action_semantics.py +411 -0
- agent_evolve/core/authored.py +105 -0
- agent_evolve/core/formatting.py +286 -0
- agent_evolve/core/optimization_semantics.py +324 -0
- agent_evolve/core/problem.py +167 -0
- agent_evolve/core/results.py +323 -0
- agent_evolve/core/stats.py +70 -0
- agent_evolve/core/telemetry.py +100 -0
- agent_evolve/domain/__init__.py +89 -0
- agent_evolve/domain/artifact.py +162 -0
- agent_evolve/domain/durable_text.py +68 -0
- agent_evolve/domain/event.py +1454 -0
- agent_evolve/domain/finite_action_set.py +426 -0
- agent_evolve/domain/finite_variation.py +526 -0
- agent_evolve/domain/generative_emission.py +559 -0
- agent_evolve/domain/ids.py +163 -0
- agent_evolve/domain/inline_text.py +106 -0
- agent_evolve/domain/insight.py +27 -0
- agent_evolve/domain/lineage.py +737 -0
- agent_evolve/domain/llm_task_queue.py +960 -0
- agent_evolve/domain/outcome.py +96 -0
- agent_evolve/domain/patch.py +854 -0
- agent_evolve/domain/typed_json.py +542 -0
- agent_evolve/domain/variation_space.py +158 -0
- agent_evolve/driver.py +1014 -0
- agent_evolve/harness/__init__.py +29 -0
- agent_evolve/harness/base.py +242 -0
- agent_evolve/harness/directives.py +163 -0
- agent_evolve/harness/generative_seal.py +479 -0
- agent_evolve/harness/registry.py +41 -0
- agent_evolve/infrastructure/__init__.py +39 -0
- agent_evolve/infrastructure/artifacts/__init__.py +6 -0
- agent_evolve/infrastructure/artifacts/_verification.py +67 -0
- agent_evolve/infrastructure/artifacts/filesystem.py +343 -0
- agent_evolve/infrastructure/artifacts/in_memory.py +73 -0
- agent_evolve/infrastructure/asyncio_runtime.py +109 -0
- agent_evolve/infrastructure/authored_runtime.py +188 -0
- agent_evolve/infrastructure/authored_worker.py +171 -0
- agent_evolve/infrastructure/clock.py +53 -0
- agent_evolve/infrastructure/events/__init__.py +6 -0
- agent_evolve/infrastructure/events/_validation.py +89 -0
- agent_evolve/infrastructure/events/in_memory.py +56 -0
- agent_evolve/infrastructure/events/jsonl.py +193 -0
- agent_evolve/infrastructure/exception_provenance.py +215 -0
- agent_evolve/infrastructure/ids.py +118 -0
- agent_evolve/infrastructure/lineage_codec.py +1836 -0
- agent_evolve/infrastructure/outcome_adaptive_phase_journal.py +170 -0
- agent_evolve/infrastructure/residual_headroom_journal.py +221 -0
- agent_evolve/infrastructure/resource_lease.py +370 -0
- agent_evolve/infrastructure/sanitization/__init__.py +8 -0
- agent_evolve/infrastructure/sanitization/strict_json.py +484 -0
- agent_evolve/infrastructure/sequential_phase_journal.py +170 -0
- agent_evolve/infrastructure/stream_liveness.py +383 -0
- agent_evolve/infrastructure/subprocess_boundary.py +136 -0
- agent_evolve/integrations/__init__.py +1 -0
- agent_evolve/integrations/botorch/__init__.py +28 -0
- agent_evolve/integrations/botorch/finite_qlognehvi.py +190 -0
- agent_evolve/integrations/botorch/finite_qlognehvi_batch.py +155 -0
- agent_evolve/integrations/botorch/finite_qlognehvi_batch_identity.py +20 -0
- agent_evolve/integrations/botorch/finite_qlognehvi_batch_worker.py +55 -0
- agent_evolve/integrations/botorch/finite_qlognehvi_identity.py +22 -0
- agent_evolve/integrations/botorch/finite_qlognehvi_worker.py +55 -0
- agent_evolve/integrations/botorch/subprocess_qlognehvi.py +261 -0
- agent_evolve/integrations/botorch/subprocess_qlognehvi_batch.py +273 -0
- agent_evolve/integrations/completion.py +242 -0
- agent_evolve/integrations/pydantic_ai/__init__.py +441 -0
- agent_evolve/integrations/pydantic_ai/action_forecast.py +1068 -0
- agent_evolve/integrations/pydantic_ai/agentic_generator.py +2308 -0
- agent_evolve/integrations/pydantic_ai/async_generator.py +1604 -0
- agent_evolve/integrations/pydantic_ai/boundary_codec.py +1526 -0
- agent_evolve/integrations/pydantic_ai/calibrated_portfolio_campaign.py +756 -0
- agent_evolve/integrations/pydantic_ai/calibrated_portfolio_selection.py +7537 -0
- agent_evolve/integrations/pydantic_ai/campaign_acquisition.py +609 -0
- agent_evolve/integrations/pydantic_ai/execution_binding.py +138 -0
- agent_evolve/integrations/pydantic_ai/forecast_geometry_action_committee.py +217 -0
- agent_evolve/integrations/pydantic_ai/harness.py +159 -0
- agent_evolve/integrations/pydantic_ai/heterogeneous_model_execution.py +306 -0
- agent_evolve/integrations/pydantic_ai/hierarchical_residual_adaptive_semantic_view.py +179 -0
- agent_evolve/integrations/pydantic_ai/json_schema_dialect.py +108 -0
- agent_evolve/integrations/pydantic_ai/materialized_hierarchical_residual_expert.py +952 -0
- agent_evolve/integrations/pydantic_ai/materialized_portfolio_judge.py +520 -0
- agent_evolve/integrations/pydantic_ai/model_execution_profile.py +659 -0
- agent_evolve/integrations/pydantic_ai/outbound_request_manifest.py +1170 -0
- agent_evolve/integrations/pydantic_ai/portable_residual_consequence_features.py +575 -0
- agent_evolve/integrations/pydantic_ai/portfolio_selection.py +422 -0
- agent_evolve/integrations/pydantic_ai/progress_aware_openrouter.py +416 -0
- agent_evolve/integrations/pydantic_ai/provider_attempt_join.py +1523 -0
- agent_evolve/integrations/pydantic_ai/provider_free_calibrated_runner.py +607 -0
- agent_evolve/integrations/pydantic_ai/queued_runner.py +2634 -0
- agent_evolve/integrations/pydantic_ai/reconciled_residual_reachability.py +1417 -0
- agent_evolve/integrations/pydantic_ai/residual_forecast_geometry.py +445 -0
- agent_evolve/integrations/pydantic_ai/residual_reachability.py +674 -0
- agent_evolve/integrations/pydantic_ai/residual_semantic_cells.py +239 -0
- agent_evolve/integrations/pydantic_ai/sealed_output_replay.py +1068 -0
- agent_evolve/integrations/pydantic_ai/semantic_coverage_residual_portfolio.py +770 -0
- agent_evolve/integrations/pydantic_ai/semantic_decision_replay.py +383 -0
- agent_evolve/integrations/pydantic_ai/support_adaptive_residual_portfolio.py +135 -0
- agent_evolve/integrations/pydantic_ai/trusted_residual_prompt_context.py +143 -0
- agent_evolve/integrations/pydantic_ai/validated_openrouter_model.py +107 -0
- agent_evolve/integrations/pymoo_adapter.py +242 -0
- agent_evolve/policies/__init__.py +17 -0
- agent_evolve/policies/check.py +469 -0
- agent_evolve/policies/emit_scaffold.py +451 -0
- agent_evolve/policies/feedback/__init__.py +37 -0
- agent_evolve/policies/feedback/held_out_asn.py +1325 -0
- agent_evolve/policies/genetic.py +607 -0
- agent_evolve/policies/llm_backoff.py +183 -0
- agent_evolve/policies/llm_chooser.py +226 -0
- agent_evolve/policies/llm_generator.py +1760 -0
- agent_evolve/policies/llm_init.py +267 -0
- agent_evolve/policies/llm_operator.py +109 -0
- agent_evolve/policies/llm_prior.py +194 -0
- agent_evolve/policies/llm_surrogate.py +334 -0
- agent_evolve/policies/measurement_evidence.py +704 -0
- agent_evolve/policies/memory/__init__.py +223 -0
- agent_evolve/policies/memory/balanced_subset_blocks.py +707 -0
- agent_evolve/policies/memory/compatibility_matching.py +593 -0
- agent_evolve/policies/memory/global_falsification.py +1841 -0
- agent_evolve/policies/memory/prompt_shape.py +503 -0
- agent_evolve/policies/memory/randomized_subset.py +714 -0
- agent_evolve/policies/memory/staged_causal.py +1270 -0
- agent_evolve/policies/memory/treatment_compliance.py +759 -0
- agent_evolve/policies/objective_resolution/__init__.py +17 -0
- agent_evolve/policies/objective_resolution/fixed_grid.py +364 -0
- agent_evolve/policies/operator_portfolio.py +407 -0
- agent_evolve/policies/reguidance.py +1133 -0
- agent_evolve/policies/reward/__init__.py +83 -0
- agent_evolve/policies/reward/affine_candidate_consequence.py +156 -0
- agent_evolve/policies/reward/affine_candidate_consequence_3d.py +159 -0
- agent_evolve/policies/reward/affine_hypervolume.py +490 -0
- agent_evolve/policies/reward/affine_hypervolume_3d.py +567 -0
- agent_evolve/policies/reward/contextual_marginal_utility.py +318 -0
- agent_evolve/policies/reward/frozen_archive.py +360 -0
- agent_evolve/policies/reward/frozen_wave_archive.py +368 -0
- agent_evolve/policies/search_state.py +208 -0
- agent_evolve/policies/selection/__init__.py +345 -0
- agent_evolve/policies/selection/acquisition_certified_slate.py +684 -0
- agent_evolve/policies/selection/affine_frontier_context.py +330 -0
- agent_evolve/policies/selection/affine_frontier_target.py +473 -0
- agent_evolve/policies/selection/archive_elite.py +1346 -0
- agent_evolve/policies/selection/calibrated_portfolio_binding.py +640 -0
- agent_evolve/policies/selection/calibrated_slate.py +1394 -0
- agent_evolve/policies/selection/calibrated_slate_codec.py +579 -0
- agent_evolve/policies/selection/common_candidate_pool.py +685 -0
- agent_evolve/policies/selection/diagnostic_sampling.py +319 -0
- agent_evolve/policies/selection/disjoint_pairs.py +479 -0
- agent_evolve/policies/selection/elite_explorer.py +719 -0
- agent_evolve/policies/selection/finite_action.py +187 -0
- agent_evolve/policies/selection/finite_option_prompt_projection.py +377 -0
- agent_evolve/policies/selection/finite_palette_evidence.py +247 -0
- agent_evolve/policies/selection/forecast_calibration.py +922 -0
- agent_evolve/policies/selection/frontier_probe_slate.py +814 -0
- agent_evolve/policies/selection/frozen_archive_pairs.py +762 -0
- agent_evolve/policies/selection/full_support_slate.py +91 -0
- agent_evolve/policies/selection/meaningful_direction.py +240 -0
- agent_evolve/policies/selection/memory_dose_feasibility.py +259 -0
- agent_evolve/policies/selection/model_anchored_slate.py +826 -0
- agent_evolve/policies/selection/phenotype_recourse.py +979 -0
- agent_evolve/policies/selection/proposal_support.py +368 -0
- agent_evolve/policies/selection/random_portfolio.py +254 -0
- agent_evolve/policies/selection/regret_bounded_slate.py +1084 -0
- agent_evolve/policies/selection/residual_frontier.py +463 -0
- agent_evolve/policies/selection/residual_frontier_target.py +605 -0
- agent_evolve/policies/selection/structural_posterior_slate.py +1571 -0
- agent_evolve/policies/selection/target_conditioned_allocator.py +648 -0
- agent_evolve/policies/selection/target_conditioned_features.py +812 -0
- agent_evolve/policies/selection/target_conditioned_prequential.py +1527 -0
- agent_evolve/policies/selection/task_keyed_palette.py +906 -0
- agent_evolve/policies/semantics.py +147 -0
- agent_evolve/policies/structure.py +362 -0
- agent_evolve/policies/structured_output_budget.py +62 -0
- agent_evolve/policies/surrogate.py +696 -0
- agent_evolve/policies/variation/__init__.py +1 -0
- agent_evolve/policies/variation/compositional_finite_catalog.py +426 -0
- agent_evolve/policies/variation/crossover_inheritance.py +575 -0
- agent_evolve/policies/variation/disjoint_recombination.py +611 -0
- agent_evolve/policies/variation/exact_composition_capacity.py +214 -0
- agent_evolve/policies/variation/exact_parent_crossover.py +950 -0
- agent_evolve/policies/variation/multiscale_restart_catalog.py +372 -0
- agent_evolve/policies/variation/source_union_finite_catalog.py +403 -0
- agent_evolve/policies/variation/typed_patch.py +1981 -0
- agent_evolve/policies/weighted_prior.py +394 -0
- agent_evolve/ports/__init__.py +383 -0
- agent_evolve/ports/action_allocation.py +733 -0
- agent_evolve/ports/action_allocation_frame.py +1153 -0
- agent_evolve/ports/action_allocation_frame_commit.py +294 -0
- agent_evolve/ports/action_allocation_frame_commit_v3.py +432 -0
- agent_evolve/ports/action_allocation_frame_v3.py +995 -0
- agent_evolve/ports/action_forecast.py +1568 -0
- agent_evolve/ports/action_metric_projection.py +165 -0
- agent_evolve/ports/agentic_generator.py +1561 -0
- agent_evolve/ports/archive_context.py +136 -0
- agent_evolve/ports/artifact_sanitizer.py +44 -0
- agent_evolve/ports/artifact_store.py +225 -0
- agent_evolve/ports/clock.py +13 -0
- agent_evolve/ports/contextual_search_allocation.py +827 -0
- agent_evolve/ports/decision_metric_projection.py +258 -0
- agent_evolve/ports/event_store.py +55 -0
- agent_evolve/ports/executable_hypothesis.py +557 -0
- agent_evolve/ports/finite_acquisition.py +377 -0
- agent_evolve/ports/finite_acquisition_batch.py +296 -0
- agent_evolve/ports/finite_acquisition_batch_json.py +164 -0
- agent_evolve/ports/finite_acquisition_json.py +247 -0
- agent_evolve/ports/finite_acquisition_space.py +168 -0
- agent_evolve/ports/finite_action_selection.py +348 -0
- agent_evolve/ports/finite_action_set.py +256 -0
- agent_evolve/ports/frontier_target.py +396 -0
- agent_evolve/ports/generation_failure.py +43 -0
- agent_evolve/ports/hard_feasibility.py +233 -0
- agent_evolve/ports/id_factory.py +34 -0
- agent_evolve/ports/llm_task_queue.py +93 -0
- agent_evolve/ports/objective_resolution.py +419 -0
- agent_evolve/ports/paired_allocation_comparison.py +401 -0
- agent_evolve/ports/paired_block_schedule.py +475 -0
- agent_evolve/ports/parent_measurement.py +336 -0
- agent_evolve/ports/portfolio_memory_dose.py +643 -0
- agent_evolve/ports/portfolio_selection.py +3169 -0
- agent_evolve/ports/postcommit_rank_authority.py +467 -0
- agent_evolve/ports/presented_action_evidence.py +794 -0
- agent_evolve/ports/resource_lease.py +162 -0
- agent_evolve/ports/structured_generator.py +734 -0
- agent_evolve/ports/structured_output_budget.py +120 -0
- agent_evolve/ports/subprocess_boundary.py +138 -0
- agent_evolve/ports/treatment_assignment.py +466 -0
- agent_evolve/ports/variation_catalog.py +76 -0
- agent_evolve/ports/variation_source.py +226 -0
- agent_evolve/proposal_mode.py +157 -0
- agent_evolve/proposers/__init__.py +10 -0
- agent_evolve/proposers/random_proposer.py +188 -0
- agent_evolve/provider_accounting.py +163 -0
- agent_evolve/py.typed +0 -0
- agent_evolve/reference_method.py +1570 -0
- agent_evolve/session/__init__.py +11 -0
- agent_evolve/session/authorship.py +864 -0
- agent_evolve/session/evaluate.py +236 -0
- agent_evolve/session/fidelity.py +237 -0
- agent_evolve/session/genetic_loop.py +742 -0
- agent_evolve/session/loop.py +803 -0
- agent_evolve/session/screening.py +671 -0
- agent_evolve/settings.py +376 -0
- agent_evolve/workload_kit.py +368 -0
- agent_evolve/workload_prompt.py +398 -0
- agentevolve_optimizer-0.5.0.dist-info/METADATA +599 -0
- agentevolve_optimizer-0.5.0.dist-info/RECORD +414 -0
- agentevolve_optimizer-0.5.0.dist-info/WHEEL +5 -0
- agentevolve_optimizer-0.5.0.dist-info/entry_points.txt +2 -0
- agentevolve_optimizer-0.5.0.dist-info/licenses/LICENSE +21 -0
- agentevolve_optimizer-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1981 @@
|
|
|
1
|
+
"""Deterministic derivation, replay, inversion, and three-way classification.
|
|
2
|
+
|
|
3
|
+
No function in this module calls a model or performs a merge. Three-way
|
|
4
|
+
classification produces explicit evidence obligations; a later operator may
|
|
5
|
+
choose or synthesize resolutions, but it cannot make conflicts disappear.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
from agent_evolve.domain.ids import CandidateId
|
|
15
|
+
from agent_evolve.domain.lineage import (
|
|
16
|
+
MAX_PRESERVATION_OBLIGATIONS,
|
|
17
|
+
AbsenceContextKind,
|
|
18
|
+
AbsenceFailureKind,
|
|
19
|
+
CandidateOccurrence,
|
|
20
|
+
PreservationClaim,
|
|
21
|
+
PreservationExpectation,
|
|
22
|
+
PreservationObligation,
|
|
23
|
+
PreservationSource,
|
|
24
|
+
VariationCase,
|
|
25
|
+
VariationKind,
|
|
26
|
+
validate_variation_case,
|
|
27
|
+
)
|
|
28
|
+
from agent_evolve.domain.patch import (
|
|
29
|
+
DEFAULT_PATCH_LIMITS,
|
|
30
|
+
MAX_PATCH_OPERATIONS,
|
|
31
|
+
ArrayIndex,
|
|
32
|
+
DeleteSequenceItem,
|
|
33
|
+
InsertSequenceItem,
|
|
34
|
+
JsonPath,
|
|
35
|
+
ObjectKey,
|
|
36
|
+
PatchLimits,
|
|
37
|
+
PatchOperation,
|
|
38
|
+
PermuteSequence,
|
|
39
|
+
ReplaceScalar,
|
|
40
|
+
ReplaceSubtree,
|
|
41
|
+
TypedPatch,
|
|
42
|
+
canonical_path_bytes,
|
|
43
|
+
operation_effect_bytes,
|
|
44
|
+
operation_effect_sha256,
|
|
45
|
+
operation_occurrence_bytes,
|
|
46
|
+
operation_kind,
|
|
47
|
+
operation_sort_key,
|
|
48
|
+
require_sha256,
|
|
49
|
+
validate_json_path,
|
|
50
|
+
validate_patch_limits,
|
|
51
|
+
validate_patch_operation,
|
|
52
|
+
validate_semantic_component,
|
|
53
|
+
validate_typed_patch,
|
|
54
|
+
)
|
|
55
|
+
from agent_evolve.domain.typed_json import (
|
|
56
|
+
FrozenJsonArray,
|
|
57
|
+
FrozenJsonObject,
|
|
58
|
+
FrozenJsonValue,
|
|
59
|
+
TypedJsonLimits,
|
|
60
|
+
canonical_typed_json_bytes,
|
|
61
|
+
freeze_json,
|
|
62
|
+
is_json_scalar,
|
|
63
|
+
typed_json_equal,
|
|
64
|
+
typed_json_sha256,
|
|
65
|
+
validate_typed_json_limits,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PatchPreconditionError(ValueError):
|
|
70
|
+
"""The supplied base or an operation-local old value was stale."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PatchPostconditionError(ValueError):
|
|
74
|
+
"""An operation or whole patch did not produce its declared endpoint."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PreservationError(ValueError):
|
|
78
|
+
"""A parent-use or preservation claim was absent, fabricated, or false."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
82
|
+
class ComponentTagAssignment:
|
|
83
|
+
"""Benchmark-supplied semantic component for one exact operation path."""
|
|
84
|
+
|
|
85
|
+
path: JsonPath
|
|
86
|
+
component: str
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
validate_json_path(self.path)
|
|
90
|
+
if type(self.component) is not str:
|
|
91
|
+
raise TypeError("component tag must be an exact string")
|
|
92
|
+
validate_semantic_component(self.component)
|
|
93
|
+
|
|
94
|
+
def __eq__(self, other: object) -> bool:
|
|
95
|
+
if (
|
|
96
|
+
type(self) is not ComponentTagAssignment
|
|
97
|
+
or type(other) is not ComponentTagAssignment
|
|
98
|
+
):
|
|
99
|
+
return False
|
|
100
|
+
ComponentTagAssignment.__post_init__(self)
|
|
101
|
+
ComponentTagAssignment.__post_init__(other)
|
|
102
|
+
return (
|
|
103
|
+
canonical_path_bytes(self.path),
|
|
104
|
+
self.component,
|
|
105
|
+
) == (
|
|
106
|
+
canonical_path_bytes(other.path),
|
|
107
|
+
other.component,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
__hash__ = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _component_assignments(
|
|
114
|
+
values: tuple[ComponentTagAssignment, ...],
|
|
115
|
+
*,
|
|
116
|
+
max_assignments: int,
|
|
117
|
+
) -> dict[bytes, str]:
|
|
118
|
+
if type(values) is not tuple:
|
|
119
|
+
raise TypeError(
|
|
120
|
+
"component_tags must be an exact tuple of ComponentTagAssignment values"
|
|
121
|
+
)
|
|
122
|
+
if len(values) > max_assignments:
|
|
123
|
+
raise ValueError("component_tags exceeds the patch max_operations bound")
|
|
124
|
+
if any(type(value) is not ComponentTagAssignment for value in values):
|
|
125
|
+
raise TypeError(
|
|
126
|
+
"component_tags must be an exact tuple of ComponentTagAssignment values"
|
|
127
|
+
)
|
|
128
|
+
for value in values:
|
|
129
|
+
ComponentTagAssignment.__post_init__(value)
|
|
130
|
+
canonical = tuple(sorted(values, key=lambda value: canonical_path_bytes(value.path)))
|
|
131
|
+
if values != canonical:
|
|
132
|
+
raise ValueError("component_tags must use canonical typed-path order")
|
|
133
|
+
result: dict[bytes, str] = {}
|
|
134
|
+
for value in values:
|
|
135
|
+
key = canonical_path_bytes(value.path)
|
|
136
|
+
if key in result:
|
|
137
|
+
raise ValueError("component_tags cannot duplicate an operation path")
|
|
138
|
+
result[key] = value.component
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _object_dict(value: FrozenJsonObject) -> dict[str, FrozenJsonValue]:
|
|
143
|
+
return {key: item for key, item in value.items}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _stable_permutation(
|
|
147
|
+
before: FrozenJsonArray,
|
|
148
|
+
after: FrozenJsonArray,
|
|
149
|
+
*,
|
|
150
|
+
limits: TypedJsonLimits,
|
|
151
|
+
) -> tuple[int, ...] | None:
|
|
152
|
+
if len(before.items) != len(after.items):
|
|
153
|
+
return None
|
|
154
|
+
positions: dict[bytes, list[int]] = {}
|
|
155
|
+
for index, item in enumerate(before.items):
|
|
156
|
+
item_bytes = canonical_typed_json_bytes(item, limits=limits)
|
|
157
|
+
positions.setdefault(item_bytes, []).append(index)
|
|
158
|
+
cursors: dict[bytes, int] = {}
|
|
159
|
+
result: list[int] = []
|
|
160
|
+
for item in after.items:
|
|
161
|
+
item_bytes = canonical_typed_json_bytes(item, limits=limits)
|
|
162
|
+
cursor = cursors.get(item_bytes, 0)
|
|
163
|
+
candidates = positions.get(item_bytes)
|
|
164
|
+
if candidates is None or cursor >= len(candidates):
|
|
165
|
+
return None
|
|
166
|
+
result.append(candidates[cursor])
|
|
167
|
+
cursors[item_bytes] = cursor + 1
|
|
168
|
+
if any(cursors.get(key, 0) != len(indices) for key, indices in positions.items()):
|
|
169
|
+
return None
|
|
170
|
+
return tuple(result)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _item_bytes(
|
|
174
|
+
sequence: FrozenJsonArray,
|
|
175
|
+
*,
|
|
176
|
+
limits: TypedJsonLimits,
|
|
177
|
+
) -> tuple[bytes, ...]:
|
|
178
|
+
return tuple(
|
|
179
|
+
canonical_typed_json_bytes(item, limits=limits) for item in sequence.items
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _single_insertion_index(
|
|
184
|
+
before: FrozenJsonArray,
|
|
185
|
+
after: FrozenJsonArray,
|
|
186
|
+
*,
|
|
187
|
+
limits: TypedJsonLimits,
|
|
188
|
+
) -> int | None:
|
|
189
|
+
"""Find the lowest valid insertion index in linear comparisons."""
|
|
190
|
+
|
|
191
|
+
if len(after.items) != len(before.items) + 1:
|
|
192
|
+
return None
|
|
193
|
+
before_items = _item_bytes(before, limits=limits)
|
|
194
|
+
after_items = _item_bytes(after, limits=limits)
|
|
195
|
+
size = len(before_items)
|
|
196
|
+
suffix_ok = [False] * (size + 1)
|
|
197
|
+
suffix_ok[size] = True
|
|
198
|
+
for index in range(size - 1, -1, -1):
|
|
199
|
+
suffix_ok[index] = (
|
|
200
|
+
before_items[index] == after_items[index + 1]
|
|
201
|
+
and suffix_ok[index + 1]
|
|
202
|
+
)
|
|
203
|
+
prefix_ok = True
|
|
204
|
+
for index in range(size + 1):
|
|
205
|
+
if prefix_ok and suffix_ok[index]:
|
|
206
|
+
return index
|
|
207
|
+
if index < size:
|
|
208
|
+
prefix_ok = prefix_ok and before_items[index] == after_items[index]
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _single_deletion_index(
|
|
213
|
+
before: FrozenJsonArray,
|
|
214
|
+
after: FrozenJsonArray,
|
|
215
|
+
*,
|
|
216
|
+
limits: TypedJsonLimits,
|
|
217
|
+
) -> int | None:
|
|
218
|
+
"""Find the lowest valid deletion index in linear comparisons."""
|
|
219
|
+
|
|
220
|
+
if len(before.items) != len(after.items) + 1:
|
|
221
|
+
return None
|
|
222
|
+
before_items = _item_bytes(before, limits=limits)
|
|
223
|
+
after_items = _item_bytes(after, limits=limits)
|
|
224
|
+
size = len(after_items)
|
|
225
|
+
suffix_ok = [False] * (size + 1)
|
|
226
|
+
suffix_ok[size] = True
|
|
227
|
+
for index in range(size - 1, -1, -1):
|
|
228
|
+
suffix_ok[index] = (
|
|
229
|
+
before_items[index + 1] == after_items[index]
|
|
230
|
+
and suffix_ok[index + 1]
|
|
231
|
+
)
|
|
232
|
+
prefix_ok = True
|
|
233
|
+
for index in range(size + 1):
|
|
234
|
+
if prefix_ok and suffix_ok[index]:
|
|
235
|
+
return index
|
|
236
|
+
if index < size:
|
|
237
|
+
prefix_ok = prefix_ok and before_items[index] == after_items[index]
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def derive_patch(
|
|
242
|
+
base: object,
|
|
243
|
+
target: object,
|
|
244
|
+
*,
|
|
245
|
+
base_candidate_id: CandidateId,
|
|
246
|
+
target_candidate_id: CandidateId,
|
|
247
|
+
component_tags: tuple[ComponentTagAssignment, ...] = (),
|
|
248
|
+
limits: PatchLimits = DEFAULT_PATCH_LIMITS,
|
|
249
|
+
) -> TypedPatch:
|
|
250
|
+
"""Derive one deterministic bounded patch between exact typed trees.
|
|
251
|
+
|
|
252
|
+
Object key-set changes are represented as an explicit subtree replacement;
|
|
253
|
+
the algebra has no implicit or path-created object insertion. A sequence
|
|
254
|
+
gets a dedicated operation for a pure permutation or exactly one insertion
|
|
255
|
+
or deletion. More complex structural edits fall back to an explicit
|
|
256
|
+
subtree replacement rather than an ambiguous order-dependent script.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
if type(base_candidate_id) is not CandidateId or type(
|
|
260
|
+
target_candidate_id
|
|
261
|
+
) is not CandidateId:
|
|
262
|
+
raise TypeError("patch endpoint IDs must be exact CandidateId values")
|
|
263
|
+
CandidateId.__post_init__(base_candidate_id)
|
|
264
|
+
CandidateId.__post_init__(target_candidate_id)
|
|
265
|
+
validate_patch_limits(limits)
|
|
266
|
+
frozen_base = freeze_json(base, limits=limits.json_limits)
|
|
267
|
+
frozen_target = freeze_json(target, limits=limits.json_limits)
|
|
268
|
+
tag_by_path = _component_assignments(
|
|
269
|
+
component_tags,
|
|
270
|
+
max_assignments=limits.max_operations,
|
|
271
|
+
)
|
|
272
|
+
used_tag_paths: set[bytes] = set()
|
|
273
|
+
operations: list[PatchOperation] = []
|
|
274
|
+
|
|
275
|
+
def tag(path: JsonPath) -> str | None:
|
|
276
|
+
path_key = canonical_path_bytes(path)
|
|
277
|
+
value = tag_by_path.get(path_key)
|
|
278
|
+
if value is not None:
|
|
279
|
+
used_tag_paths.add(path_key)
|
|
280
|
+
return value
|
|
281
|
+
|
|
282
|
+
def add(operation: PatchOperation) -> None:
|
|
283
|
+
operations.append(operation)
|
|
284
|
+
if len(operations) > limits.max_operations:
|
|
285
|
+
raise ValueError("derived patch exceeds max_operations")
|
|
286
|
+
|
|
287
|
+
def replace_at(
|
|
288
|
+
path: JsonPath,
|
|
289
|
+
before: FrozenJsonValue,
|
|
290
|
+
after: FrozenJsonValue,
|
|
291
|
+
) -> None:
|
|
292
|
+
component = tag(path)
|
|
293
|
+
if is_json_scalar(before) and is_json_scalar(after):
|
|
294
|
+
add(
|
|
295
|
+
ReplaceScalar(
|
|
296
|
+
path,
|
|
297
|
+
before,
|
|
298
|
+
after,
|
|
299
|
+
base_candidate_id,
|
|
300
|
+
component,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
add(
|
|
305
|
+
ReplaceSubtree(
|
|
306
|
+
path,
|
|
307
|
+
before,
|
|
308
|
+
after,
|
|
309
|
+
base_candidate_id,
|
|
310
|
+
component,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def visit(
|
|
315
|
+
before: FrozenJsonValue,
|
|
316
|
+
after: FrozenJsonValue,
|
|
317
|
+
path: JsonPath,
|
|
318
|
+
) -> None:
|
|
319
|
+
if typed_json_equal(before, after, limits=limits.json_limits):
|
|
320
|
+
return
|
|
321
|
+
if len(path.segments) >= limits.max_path_segments:
|
|
322
|
+
replace_at(path, before, after)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
if type(before) is FrozenJsonObject and type(after) is FrozenJsonObject:
|
|
326
|
+
before_items = _object_dict(before)
|
|
327
|
+
after_items = _object_dict(after)
|
|
328
|
+
if before_items.keys() != after_items.keys():
|
|
329
|
+
replace_at(path, before, after)
|
|
330
|
+
return
|
|
331
|
+
changed_children: list[
|
|
332
|
+
tuple[str, FrozenJsonValue, FrozenJsonValue, JsonPath]
|
|
333
|
+
] = []
|
|
334
|
+
for key, before_item in before.items:
|
|
335
|
+
after_item = after_items[key]
|
|
336
|
+
if typed_json_equal(
|
|
337
|
+
before_item,
|
|
338
|
+
after_item,
|
|
339
|
+
limits=limits.json_limits,
|
|
340
|
+
):
|
|
341
|
+
continue
|
|
342
|
+
try:
|
|
343
|
+
child_path = path.child_key(key)
|
|
344
|
+
except ValueError:
|
|
345
|
+
# The typed-JSON domain intentionally admits longer keys
|
|
346
|
+
# than one path segment. Preserve derivation totality by
|
|
347
|
+
# bubbling the edit to the nearest representable parent.
|
|
348
|
+
replace_at(path, before, after)
|
|
349
|
+
return
|
|
350
|
+
changed_children.append((key, before_item, after_item, child_path))
|
|
351
|
+
for _, before_item, after_item, child_path in changed_children:
|
|
352
|
+
visit(before_item, after_item, child_path)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
if type(before) is FrozenJsonArray and type(after) is FrozenJsonArray:
|
|
356
|
+
permutation = _stable_permutation(
|
|
357
|
+
before,
|
|
358
|
+
after,
|
|
359
|
+
limits=limits.json_limits,
|
|
360
|
+
)
|
|
361
|
+
if permutation is not None and permutation != tuple(range(len(before.items))):
|
|
362
|
+
add(
|
|
363
|
+
PermuteSequence(
|
|
364
|
+
path,
|
|
365
|
+
permutation,
|
|
366
|
+
before,
|
|
367
|
+
after,
|
|
368
|
+
base_candidate_id,
|
|
369
|
+
tag(path),
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if len(after.items) == len(before.items) + 1:
|
|
375
|
+
index = _single_insertion_index(
|
|
376
|
+
before,
|
|
377
|
+
after,
|
|
378
|
+
limits=limits.json_limits,
|
|
379
|
+
)
|
|
380
|
+
if index is not None:
|
|
381
|
+
add(
|
|
382
|
+
InsertSequenceItem(
|
|
383
|
+
path,
|
|
384
|
+
index,
|
|
385
|
+
after.items[index],
|
|
386
|
+
before,
|
|
387
|
+
after,
|
|
388
|
+
base_candidate_id,
|
|
389
|
+
tag(path),
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
return
|
|
393
|
+
replace_at(path, before, after)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
if len(before.items) == len(after.items) + 1:
|
|
397
|
+
index = _single_deletion_index(
|
|
398
|
+
before,
|
|
399
|
+
after,
|
|
400
|
+
limits=limits.json_limits,
|
|
401
|
+
)
|
|
402
|
+
if index is not None:
|
|
403
|
+
add(
|
|
404
|
+
DeleteSequenceItem(
|
|
405
|
+
path,
|
|
406
|
+
index,
|
|
407
|
+
before.items[index],
|
|
408
|
+
before,
|
|
409
|
+
after,
|
|
410
|
+
base_candidate_id,
|
|
411
|
+
tag(path),
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
return
|
|
415
|
+
replace_at(path, before, after)
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
if len(before.items) == len(after.items):
|
|
419
|
+
for index, (before_item, after_item) in enumerate(
|
|
420
|
+
zip(before.items, after.items)
|
|
421
|
+
):
|
|
422
|
+
visit(before_item, after_item, path.child_index(index))
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
replace_at(path, before, after)
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
replace_at(path, before, after)
|
|
429
|
+
|
|
430
|
+
visit(frozen_base, frozen_target, JsonPath())
|
|
431
|
+
unused_tags = set(tag_by_path) - used_tag_paths
|
|
432
|
+
if unused_tags:
|
|
433
|
+
raise ValueError("component_tags contains a path that is not a derived operation")
|
|
434
|
+
ordered = tuple(sorted(operations, key=operation_sort_key))
|
|
435
|
+
return TypedPatch(
|
|
436
|
+
base_candidate_id=base_candidate_id,
|
|
437
|
+
target_candidate_id=target_candidate_id,
|
|
438
|
+
base_hash=typed_json_sha256(frozen_base, limits=limits.json_limits),
|
|
439
|
+
target_hash=typed_json_sha256(frozen_target, limits=limits.json_limits),
|
|
440
|
+
operations=ordered,
|
|
441
|
+
limits=limits,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def value_at_path(value: FrozenJsonValue, path: JsonPath) -> FrozenJsonValue:
|
|
446
|
+
"""Resolve an existing typed path without invoking container subclasses."""
|
|
447
|
+
|
|
448
|
+
validate_json_path(path)
|
|
449
|
+
current = freeze_json(value)
|
|
450
|
+
if current is not value:
|
|
451
|
+
raise TypeError("value_at_path requires an already frozen typed-JSON value")
|
|
452
|
+
for segment in path.segments:
|
|
453
|
+
if type(segment) is ObjectKey:
|
|
454
|
+
if type(current) is not FrozenJsonObject:
|
|
455
|
+
raise PatchPreconditionError("object-key segment reached a non-object")
|
|
456
|
+
found = False
|
|
457
|
+
for key, item in current.items:
|
|
458
|
+
if key == segment.value:
|
|
459
|
+
current = item
|
|
460
|
+
found = True
|
|
461
|
+
break
|
|
462
|
+
if not found:
|
|
463
|
+
raise PatchPreconditionError("object-key path does not exist")
|
|
464
|
+
elif type(segment) is ArrayIndex:
|
|
465
|
+
if type(current) is not FrozenJsonArray:
|
|
466
|
+
raise PatchPreconditionError("array-index segment reached a non-array")
|
|
467
|
+
if segment.value >= len(current.items):
|
|
468
|
+
raise PatchPreconditionError("array-index path is out of bounds")
|
|
469
|
+
current = current.items[segment.value]
|
|
470
|
+
else: # pragma: no cover - JsonPath closes this union.
|
|
471
|
+
raise AssertionError("unsupported path segment")
|
|
472
|
+
return current
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _replace_existing_path(
|
|
476
|
+
value: FrozenJsonValue,
|
|
477
|
+
path: JsonPath,
|
|
478
|
+
replacement: FrozenJsonValue,
|
|
479
|
+
) -> FrozenJsonValue:
|
|
480
|
+
if not path.segments:
|
|
481
|
+
return replacement
|
|
482
|
+
head = path.segments[0]
|
|
483
|
+
tail = JsonPath(path.segments[1:])
|
|
484
|
+
if type(head) is ObjectKey:
|
|
485
|
+
if type(value) is not FrozenJsonObject:
|
|
486
|
+
raise PatchPreconditionError("object-key replacement reached a non-object")
|
|
487
|
+
found = False
|
|
488
|
+
updated: list[tuple[str, FrozenJsonValue]] = []
|
|
489
|
+
for key, item in value.items:
|
|
490
|
+
if key == head.value:
|
|
491
|
+
found = True
|
|
492
|
+
updated.append((key, _replace_existing_path(item, tail, replacement)))
|
|
493
|
+
else:
|
|
494
|
+
updated.append((key, item))
|
|
495
|
+
if not found:
|
|
496
|
+
raise PatchPreconditionError(
|
|
497
|
+
"object-key replacement cannot insert a missing key"
|
|
498
|
+
)
|
|
499
|
+
return FrozenJsonObject(tuple(updated))
|
|
500
|
+
if type(head) is ArrayIndex:
|
|
501
|
+
if type(value) is not FrozenJsonArray:
|
|
502
|
+
raise PatchPreconditionError("array-index replacement reached a non-array")
|
|
503
|
+
if head.value >= len(value.items):
|
|
504
|
+
raise PatchPreconditionError("array-index replacement is out of bounds")
|
|
505
|
+
items = list(value.items)
|
|
506
|
+
items[head.value] = _replace_existing_path(items[head.value], tail, replacement)
|
|
507
|
+
return FrozenJsonArray(tuple(items))
|
|
508
|
+
raise AssertionError("unsupported path segment")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def replace_existing_path(
|
|
512
|
+
value: FrozenJsonValue,
|
|
513
|
+
path: JsonPath,
|
|
514
|
+
replacement: FrozenJsonValue,
|
|
515
|
+
) -> FrozenJsonValue:
|
|
516
|
+
"""Return an immutable tree with one existing typed path replaced.
|
|
517
|
+
|
|
518
|
+
This is the narrow construction primitive used before deriving a canonical
|
|
519
|
+
patch. It cannot insert a key or grow an array, and it accepts only values
|
|
520
|
+
that are already inside the frozen typed-JSON boundary.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
validate_json_path(path)
|
|
524
|
+
frozen_value = freeze_json(value)
|
|
525
|
+
if frozen_value is not value:
|
|
526
|
+
raise TypeError("value must already be frozen typed JSON")
|
|
527
|
+
frozen_replacement = freeze_json(replacement)
|
|
528
|
+
if frozen_replacement is not replacement:
|
|
529
|
+
raise TypeError("replacement must already be frozen typed JSON")
|
|
530
|
+
# Resolve first so every missing/stale non-root path fails before any
|
|
531
|
+
# provisional target is constructed (the root is itself an existing path).
|
|
532
|
+
value_at_path(value, path)
|
|
533
|
+
return _replace_existing_path(value, path, replacement)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def apply_patch(base: object, patch: TypedPatch) -> FrozenJsonValue:
|
|
537
|
+
"""Apply a patch only when every local and global precondition is exact."""
|
|
538
|
+
|
|
539
|
+
validate_typed_patch(patch)
|
|
540
|
+
current = freeze_json(base, limits=patch.limits.json_limits)
|
|
541
|
+
if typed_json_sha256(current, limits=patch.limits.json_limits) != patch.base_hash:
|
|
542
|
+
raise PatchPreconditionError("base hash does not match patch.base_hash")
|
|
543
|
+
|
|
544
|
+
for operation in patch.operations:
|
|
545
|
+
observed = value_at_path(current, operation.path)
|
|
546
|
+
if type(operation) in (ReplaceScalar, ReplaceSubtree):
|
|
547
|
+
expected_before = operation.old_value
|
|
548
|
+
replacement = operation.new_value
|
|
549
|
+
else:
|
|
550
|
+
expected_before = operation.before_sequence
|
|
551
|
+
if type(observed) is not FrozenJsonArray:
|
|
552
|
+
raise PatchPreconditionError("sequence operation reached a non-array")
|
|
553
|
+
if type(operation) is InsertSequenceItem:
|
|
554
|
+
replacement = FrozenJsonArray(
|
|
555
|
+
observed.items[: operation.index]
|
|
556
|
+
+ (operation.item,)
|
|
557
|
+
+ observed.items[operation.index :]
|
|
558
|
+
)
|
|
559
|
+
elif type(operation) is DeleteSequenceItem:
|
|
560
|
+
if operation.index >= len(observed.items):
|
|
561
|
+
raise PatchPreconditionError("delete index is stale")
|
|
562
|
+
if not typed_json_equal(
|
|
563
|
+
observed.items[operation.index],
|
|
564
|
+
operation.item,
|
|
565
|
+
limits=patch.limits.json_limits,
|
|
566
|
+
):
|
|
567
|
+
raise PatchPreconditionError("deleted item precondition is stale")
|
|
568
|
+
replacement = FrozenJsonArray(
|
|
569
|
+
observed.items[: operation.index]
|
|
570
|
+
+ observed.items[operation.index + 1 :]
|
|
571
|
+
)
|
|
572
|
+
elif type(operation) is PermuteSequence:
|
|
573
|
+
if len(observed.items) != len(operation.permutation):
|
|
574
|
+
raise PatchPreconditionError("permutation length is stale")
|
|
575
|
+
replacement = FrozenJsonArray(
|
|
576
|
+
tuple(observed.items[index] for index in operation.permutation)
|
|
577
|
+
)
|
|
578
|
+
else: # pragma: no cover - TypedPatch closes operation types.
|
|
579
|
+
raise AssertionError("unsupported patch operation")
|
|
580
|
+
if not typed_json_equal(
|
|
581
|
+
observed,
|
|
582
|
+
expected_before,
|
|
583
|
+
limits=patch.limits.json_limits,
|
|
584
|
+
):
|
|
585
|
+
raise PatchPreconditionError("operation old-value precondition is stale")
|
|
586
|
+
expected_after = (
|
|
587
|
+
operation.new_value
|
|
588
|
+
if type(operation) in (ReplaceScalar, ReplaceSubtree)
|
|
589
|
+
else operation.after_sequence
|
|
590
|
+
)
|
|
591
|
+
if not typed_json_equal(
|
|
592
|
+
replacement,
|
|
593
|
+
expected_after,
|
|
594
|
+
limits=patch.limits.json_limits,
|
|
595
|
+
):
|
|
596
|
+
raise PatchPostconditionError("operation result violates its postcondition")
|
|
597
|
+
current = _replace_existing_path(current, operation.path, replacement)
|
|
598
|
+
if not typed_json_equal(
|
|
599
|
+
value_at_path(current, operation.path),
|
|
600
|
+
expected_after,
|
|
601
|
+
limits=patch.limits.json_limits,
|
|
602
|
+
):
|
|
603
|
+
raise PatchPostconditionError("operation path was not replaced exactly")
|
|
604
|
+
|
|
605
|
+
actual_target_hash = typed_json_sha256(current, limits=patch.limits.json_limits)
|
|
606
|
+
if actual_target_hash != patch.target_hash:
|
|
607
|
+
raise PatchPostconditionError("patch result does not match target_hash")
|
|
608
|
+
return current
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def invert_patch(patch: TypedPatch) -> TypedPatch:
|
|
612
|
+
"""Build the canonical exact inverse; applying it restores the base tree."""
|
|
613
|
+
|
|
614
|
+
validate_typed_patch(patch)
|
|
615
|
+
inverse: list[PatchOperation] = []
|
|
616
|
+
source = patch.target_candidate_id
|
|
617
|
+
for operation in patch.operations:
|
|
618
|
+
if type(operation) is ReplaceScalar:
|
|
619
|
+
inverse.append(
|
|
620
|
+
ReplaceScalar(
|
|
621
|
+
operation.path,
|
|
622
|
+
operation.new_value,
|
|
623
|
+
operation.old_value,
|
|
624
|
+
source,
|
|
625
|
+
operation.semantic_component,
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
elif type(operation) is ReplaceSubtree:
|
|
629
|
+
inverse.append(
|
|
630
|
+
ReplaceSubtree(
|
|
631
|
+
operation.path,
|
|
632
|
+
operation.new_value,
|
|
633
|
+
operation.old_value,
|
|
634
|
+
source,
|
|
635
|
+
operation.semantic_component,
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
elif type(operation) is InsertSequenceItem:
|
|
639
|
+
inverse.append(
|
|
640
|
+
DeleteSequenceItem(
|
|
641
|
+
operation.path,
|
|
642
|
+
operation.index,
|
|
643
|
+
operation.item,
|
|
644
|
+
operation.after_sequence,
|
|
645
|
+
operation.before_sequence,
|
|
646
|
+
source,
|
|
647
|
+
operation.semantic_component,
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
elif type(operation) is DeleteSequenceItem:
|
|
651
|
+
inverse.append(
|
|
652
|
+
InsertSequenceItem(
|
|
653
|
+
operation.path,
|
|
654
|
+
operation.index,
|
|
655
|
+
operation.item,
|
|
656
|
+
operation.after_sequence,
|
|
657
|
+
operation.before_sequence,
|
|
658
|
+
source,
|
|
659
|
+
operation.semantic_component,
|
|
660
|
+
)
|
|
661
|
+
)
|
|
662
|
+
elif type(operation) is PermuteSequence:
|
|
663
|
+
inverse_permutation = _stable_permutation(
|
|
664
|
+
operation.after_sequence,
|
|
665
|
+
operation.before_sequence,
|
|
666
|
+
limits=patch.limits.json_limits,
|
|
667
|
+
)
|
|
668
|
+
if inverse_permutation is None: # pragma: no cover - constructor proves it.
|
|
669
|
+
raise AssertionError("validated permutation had no inverse")
|
|
670
|
+
inverse.append(
|
|
671
|
+
PermuteSequence(
|
|
672
|
+
operation.path,
|
|
673
|
+
inverse_permutation,
|
|
674
|
+
operation.after_sequence,
|
|
675
|
+
operation.before_sequence,
|
|
676
|
+
source,
|
|
677
|
+
operation.semantic_component,
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
else: # pragma: no cover - TypedPatch closes operation types.
|
|
681
|
+
raise AssertionError("unsupported patch operation")
|
|
682
|
+
return TypedPatch(
|
|
683
|
+
base_candidate_id=patch.target_candidate_id,
|
|
684
|
+
target_candidate_id=patch.base_candidate_id,
|
|
685
|
+
base_hash=patch.target_hash,
|
|
686
|
+
target_hash=patch.base_hash,
|
|
687
|
+
operations=tuple(sorted(inverse, key=operation_sort_key)),
|
|
688
|
+
limits=patch.limits,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class ThreeWayRelationKind(str, Enum):
|
|
693
|
+
IDENTICAL = "identical"
|
|
694
|
+
DISJOINT = "disjoint"
|
|
695
|
+
COMPATIBLE_SAME_COMPONENT = "compatible_same_component"
|
|
696
|
+
CONFLICT = "conflict"
|
|
697
|
+
INVALIDATED = "invalidated"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@dataclass(frozen=True, slots=True)
|
|
701
|
+
class _CrossPathIndex:
|
|
702
|
+
equal_pairs: tuple[tuple[int, int], ...]
|
|
703
|
+
strict_edges: tuple[tuple[int, int], ...]
|
|
704
|
+
work_units: int
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _validate_operation_tuple(
|
|
708
|
+
operations: tuple[PatchOperation, ...],
|
|
709
|
+
*,
|
|
710
|
+
name: str,
|
|
711
|
+
) -> None:
|
|
712
|
+
if type(operations) is not tuple:
|
|
713
|
+
raise TypeError(f"{name} must be an exact tuple")
|
|
714
|
+
if len(operations) > MAX_PATCH_OPERATIONS:
|
|
715
|
+
raise ValueError(f"{name} exceeds MAX_PATCH_OPERATIONS")
|
|
716
|
+
for operation in operations:
|
|
717
|
+
validate_patch_operation(operation)
|
|
718
|
+
if operations != tuple(sorted(operations, key=operation_sort_key)):
|
|
719
|
+
raise ValueError(f"{name} must use canonical operation order")
|
|
720
|
+
terminal = object()
|
|
721
|
+
root: dict[object, object] = {}
|
|
722
|
+
for operation in operations:
|
|
723
|
+
node = root
|
|
724
|
+
for segment in operation.path.segments:
|
|
725
|
+
if terminal in node:
|
|
726
|
+
raise ValueError(f"{name} contains overlapping operation paths")
|
|
727
|
+
child = node.setdefault(segment, {})
|
|
728
|
+
if type(child) is not dict: # pragma: no cover - local trie invariant.
|
|
729
|
+
raise AssertionError("invalid relation-side path trie")
|
|
730
|
+
node = child
|
|
731
|
+
if terminal in node or node:
|
|
732
|
+
raise ValueError(f"{name} contains overlapping operation paths")
|
|
733
|
+
node[terminal] = True
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _index_cross_path_relations(
|
|
737
|
+
left: tuple[PatchOperation, ...],
|
|
738
|
+
right: tuple[PatchOperation, ...],
|
|
739
|
+
) -> _CrossPathIndex:
|
|
740
|
+
"""Index equal and strict-prefix pairs in linear trie work plus output edges.
|
|
741
|
+
|
|
742
|
+
Each side is already internally prefix-free. Consequently the descendant
|
|
743
|
+
subtrees visited for distinct right-side terminals are disjoint, so the
|
|
744
|
+
accumulated ``work_units`` is linear in total path length and emitted
|
|
745
|
+
strict-overlap edges rather than the Cartesian product of branch sizes.
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
_validate_operation_tuple(left, name="left_operations")
|
|
749
|
+
_validate_operation_tuple(right, name="right_operations")
|
|
750
|
+
terminal = object()
|
|
751
|
+
root: dict[object, object] = {}
|
|
752
|
+
work_units = 0
|
|
753
|
+
for index, operation in enumerate(left):
|
|
754
|
+
node = root
|
|
755
|
+
for segment in operation.path.segments:
|
|
756
|
+
work_units += 1
|
|
757
|
+
child = node.setdefault(segment, {})
|
|
758
|
+
if type(child) is not dict: # pragma: no cover - local trie invariant.
|
|
759
|
+
raise AssertionError("invalid cross-branch path trie")
|
|
760
|
+
node = child
|
|
761
|
+
node[terminal] = index
|
|
762
|
+
|
|
763
|
+
equal_pairs: list[tuple[int, int]] = []
|
|
764
|
+
strict_edges: list[tuple[int, int]] = []
|
|
765
|
+
for right_index, operation in enumerate(right):
|
|
766
|
+
node = root
|
|
767
|
+
found = True
|
|
768
|
+
for segment in operation.path.segments:
|
|
769
|
+
work_units += 1
|
|
770
|
+
left_ancestor = node.get(terminal)
|
|
771
|
+
if type(left_ancestor) is int:
|
|
772
|
+
strict_edges.append((left_ancestor, right_index))
|
|
773
|
+
child = node.get(segment)
|
|
774
|
+
if type(child) is not dict:
|
|
775
|
+
found = False
|
|
776
|
+
break
|
|
777
|
+
node = child
|
|
778
|
+
if not found:
|
|
779
|
+
continue
|
|
780
|
+
exact = node.get(terminal)
|
|
781
|
+
if type(exact) is int:
|
|
782
|
+
equal_pairs.append((exact, right_index))
|
|
783
|
+
continue
|
|
784
|
+
stack = [
|
|
785
|
+
child
|
|
786
|
+
for key, child in node.items()
|
|
787
|
+
if key is not terminal and type(child) is dict
|
|
788
|
+
]
|
|
789
|
+
while stack:
|
|
790
|
+
descendant = stack.pop()
|
|
791
|
+
work_units += 1
|
|
792
|
+
left_descendant = descendant.get(terminal)
|
|
793
|
+
if type(left_descendant) is int:
|
|
794
|
+
strict_edges.append((left_descendant, right_index))
|
|
795
|
+
stack.extend(
|
|
796
|
+
child
|
|
797
|
+
for key, child in descendant.items()
|
|
798
|
+
if key is not terminal and type(child) is dict
|
|
799
|
+
)
|
|
800
|
+
return _CrossPathIndex(
|
|
801
|
+
tuple(sorted(equal_pairs)),
|
|
802
|
+
tuple(sorted(set(strict_edges))),
|
|
803
|
+
work_units,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
808
|
+
class PatchRelation:
|
|
809
|
+
kind: ThreeWayRelationKind
|
|
810
|
+
left_operations: tuple[PatchOperation, ...]
|
|
811
|
+
right_operations: tuple[PatchOperation, ...]
|
|
812
|
+
semantic_component: str | None = None
|
|
813
|
+
|
|
814
|
+
def __post_init__(self) -> None:
|
|
815
|
+
# Validate before any equality or encoding. A string-like object with
|
|
816
|
+
# hostile __eq__/encode methods must never influence canonical replay or
|
|
817
|
+
# relation identity.
|
|
818
|
+
validate_semantic_component(self.semantic_component)
|
|
819
|
+
if type(self.kind) is not ThreeWayRelationKind:
|
|
820
|
+
raise TypeError("kind must be a ThreeWayRelationKind")
|
|
821
|
+
for name, operations in (
|
|
822
|
+
("left_operations", self.left_operations),
|
|
823
|
+
("right_operations", self.right_operations),
|
|
824
|
+
):
|
|
825
|
+
_validate_operation_tuple(operations, name=name)
|
|
826
|
+
left = self.left_operations
|
|
827
|
+
right = self.right_operations
|
|
828
|
+
if self.kind is ThreeWayRelationKind.IDENTICAL:
|
|
829
|
+
if len(left) != 1 or len(right) != 1:
|
|
830
|
+
raise ValueError("identical relations require one operation per branch")
|
|
831
|
+
if operation_effect_bytes(left[0]) != operation_effect_bytes(right[0]):
|
|
832
|
+
raise ValueError("identical relation operations are not exact effects")
|
|
833
|
+
elif self.kind is ThreeWayRelationKind.CONFLICT:
|
|
834
|
+
if len(left) != 1 or len(right) != 1 or left[0].path != right[0].path:
|
|
835
|
+
raise ValueError("conflicts require different effects at one exact path")
|
|
836
|
+
if operation_effect_bytes(left[0]) == operation_effect_bytes(right[0]):
|
|
837
|
+
raise ValueError("identical effects cannot be classified as conflicts")
|
|
838
|
+
elif self.kind is ThreeWayRelationKind.INVALIDATED:
|
|
839
|
+
if not left or not right:
|
|
840
|
+
raise ValueError("invalidated relations require operations on both branches")
|
|
841
|
+
indexed = _index_cross_path_relations(left, right)
|
|
842
|
+
if indexed.equal_pairs:
|
|
843
|
+
raise ValueError("invalidated relation cannot contain equal paths")
|
|
844
|
+
incident_left = {left_index for left_index, _ in indexed.strict_edges}
|
|
845
|
+
incident_right = {right_index for _, right_index in indexed.strict_edges}
|
|
846
|
+
if incident_left != set(range(len(left))) or incident_right != set(
|
|
847
|
+
range(len(right))
|
|
848
|
+
):
|
|
849
|
+
raise ValueError("invalidated relation lacks strict prefix invalidation")
|
|
850
|
+
graph: dict[tuple[str, int], set[tuple[str, int]]] = {}
|
|
851
|
+
for left_index, right_index in indexed.strict_edges:
|
|
852
|
+
left_node = ("left", left_index)
|
|
853
|
+
right_node = ("right", right_index)
|
|
854
|
+
graph.setdefault(left_node, set()).add(right_node)
|
|
855
|
+
graph.setdefault(right_node, set()).add(left_node)
|
|
856
|
+
visited: set[tuple[str, int]] = set()
|
|
857
|
+
stack = [next(iter(graph))]
|
|
858
|
+
while stack:
|
|
859
|
+
node = stack.pop()
|
|
860
|
+
if node in visited:
|
|
861
|
+
continue
|
|
862
|
+
visited.add(node)
|
|
863
|
+
stack.extend(graph[node] - visited)
|
|
864
|
+
if len(visited) != len(left) + len(right):
|
|
865
|
+
raise ValueError("invalidated relation must be one connected component")
|
|
866
|
+
elif self.kind is ThreeWayRelationKind.COMPATIBLE_SAME_COMPONENT:
|
|
867
|
+
if not left or not right or self.semantic_component is None:
|
|
868
|
+
raise ValueError(
|
|
869
|
+
"compatible same-component relations require both branches and a tag"
|
|
870
|
+
)
|
|
871
|
+
if any(
|
|
872
|
+
operation.semantic_component != self.semantic_component
|
|
873
|
+
for operation in left + right
|
|
874
|
+
):
|
|
875
|
+
raise ValueError("compatible relation component tags do not agree")
|
|
876
|
+
indexed = _index_cross_path_relations(left, right)
|
|
877
|
+
if indexed.equal_pairs or indexed.strict_edges:
|
|
878
|
+
raise ValueError("overlapping paths are not compatible disjoint edits")
|
|
879
|
+
elif self.kind is ThreeWayRelationKind.DISJOINT:
|
|
880
|
+
if len(left) + len(right) != 1:
|
|
881
|
+
raise ValueError("a disjoint relation contains one unmatched branch edit")
|
|
882
|
+
else: # pragma: no cover - enum closes cases.
|
|
883
|
+
raise AssertionError("unsupported relation kind")
|
|
884
|
+
if self.kind is not ThreeWayRelationKind.COMPATIBLE_SAME_COMPONENT and (
|
|
885
|
+
self.semantic_component is not None
|
|
886
|
+
):
|
|
887
|
+
raise ValueError("semantic_component is only stored on compatible relations")
|
|
888
|
+
|
|
889
|
+
def __eq__(self, other: object) -> bool:
|
|
890
|
+
if type(self) is not PatchRelation or type(other) is not PatchRelation:
|
|
891
|
+
return False
|
|
892
|
+
return _relation_fingerprint(self) == _relation_fingerprint(other)
|
|
893
|
+
|
|
894
|
+
__hash__ = None
|
|
895
|
+
|
|
896
|
+
@property
|
|
897
|
+
def relation_id(self) -> str:
|
|
898
|
+
validate_patch_relation(self)
|
|
899
|
+
digest = hashlib.sha256()
|
|
900
|
+
digest.update(b"agent-evolve:three-way-relation:v1\x00")
|
|
901
|
+
digest.update(self.kind.value.encode("ascii", errors="strict"))
|
|
902
|
+
component = (
|
|
903
|
+
b""
|
|
904
|
+
if self.semantic_component is None
|
|
905
|
+
else self.semantic_component.encode("utf-8", errors="strict")
|
|
906
|
+
)
|
|
907
|
+
digest.update(len(component).to_bytes(8, "big", signed=False))
|
|
908
|
+
digest.update(component)
|
|
909
|
+
for marker, operations in (
|
|
910
|
+
(b"L", self.left_operations),
|
|
911
|
+
(b"R", self.right_operations),
|
|
912
|
+
):
|
|
913
|
+
digest.update(marker)
|
|
914
|
+
digest.update(len(operations).to_bytes(8, "big", signed=False))
|
|
915
|
+
for operation in operations:
|
|
916
|
+
occurrence = operation_occurrence_bytes(operation)
|
|
917
|
+
digest.update(len(occurrence).to_bytes(8, "big", signed=False))
|
|
918
|
+
digest.update(occurrence)
|
|
919
|
+
return digest.hexdigest()
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def validate_patch_relation(relation: PatchRelation) -> None:
|
|
923
|
+
"""Revalidate an exact relation and every nested operation."""
|
|
924
|
+
|
|
925
|
+
if type(relation) is not PatchRelation:
|
|
926
|
+
raise TypeError("relation must be an exact PatchRelation")
|
|
927
|
+
PatchRelation.__post_init__(relation)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _relation_sort_key(relation: PatchRelation) -> tuple[bytes, str, str]:
|
|
931
|
+
paths = [
|
|
932
|
+
canonical_path_bytes(operation.path)
|
|
933
|
+
for operation in relation.left_operations + relation.right_operations
|
|
934
|
+
]
|
|
935
|
+
return min(paths), relation.kind.value, relation.relation_id
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _derive_three_way_relations(
|
|
939
|
+
left_patch: TypedPatch,
|
|
940
|
+
right_patch: TypedPatch,
|
|
941
|
+
) -> tuple[PatchRelation, ...]:
|
|
942
|
+
"""Derive the one canonical global relation partition from exact patches."""
|
|
943
|
+
|
|
944
|
+
validate_typed_patch(left_patch)
|
|
945
|
+
validate_typed_patch(right_patch)
|
|
946
|
+
left = left_patch.operations
|
|
947
|
+
right = right_patch.operations
|
|
948
|
+
indexed = _index_cross_path_relations(left, right)
|
|
949
|
+
relations: list[PatchRelation] = []
|
|
950
|
+
consumed_left: set[int] = set()
|
|
951
|
+
consumed_right: set[int] = set()
|
|
952
|
+
|
|
953
|
+
for left_index, right_index in indexed.equal_pairs:
|
|
954
|
+
left_operation = left[left_index]
|
|
955
|
+
right_operation = right[right_index]
|
|
956
|
+
kind = (
|
|
957
|
+
ThreeWayRelationKind.IDENTICAL
|
|
958
|
+
if operation_effect_bytes(left_operation)
|
|
959
|
+
== operation_effect_bytes(right_operation)
|
|
960
|
+
else ThreeWayRelationKind.CONFLICT
|
|
961
|
+
)
|
|
962
|
+
relations.append(PatchRelation(kind, (left_operation,), (right_operation,)))
|
|
963
|
+
consumed_left.add(left_index)
|
|
964
|
+
consumed_right.add(right_index)
|
|
965
|
+
|
|
966
|
+
adjacency: dict[tuple[str, int], set[tuple[str, int]]] = {}
|
|
967
|
+
for left_index, right_index in indexed.strict_edges:
|
|
968
|
+
left_node = ("left", left_index)
|
|
969
|
+
right_node = ("right", right_index)
|
|
970
|
+
adjacency.setdefault(left_node, set()).add(right_node)
|
|
971
|
+
adjacency.setdefault(right_node, set()).add(left_node)
|
|
972
|
+
visited: set[tuple[str, int]] = set()
|
|
973
|
+
for seed in sorted(adjacency):
|
|
974
|
+
if seed in visited:
|
|
975
|
+
continue
|
|
976
|
+
stack = [seed]
|
|
977
|
+
component_nodes: set[tuple[str, bytes]] = set()
|
|
978
|
+
while stack:
|
|
979
|
+
node = stack.pop()
|
|
980
|
+
if node in component_nodes:
|
|
981
|
+
continue
|
|
982
|
+
component_nodes.add(node)
|
|
983
|
+
stack.extend(adjacency.get(node, ()))
|
|
984
|
+
visited.update(component_nodes)
|
|
985
|
+
left_indices = sorted(index for side, index in component_nodes if side == "left")
|
|
986
|
+
right_indices = sorted(index for side, index in component_nodes if side == "right")
|
|
987
|
+
consumed_left.update(left_indices)
|
|
988
|
+
consumed_right.update(right_indices)
|
|
989
|
+
relations.append(
|
|
990
|
+
PatchRelation(
|
|
991
|
+
ThreeWayRelationKind.INVALIDATED,
|
|
992
|
+
tuple(left[index] for index in left_indices),
|
|
993
|
+
tuple(right[index] for index in right_indices),
|
|
994
|
+
)
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
left_components: dict[str, list[int]] = {}
|
|
998
|
+
right_components: dict[str, list[int]] = {}
|
|
999
|
+
for index, operation in enumerate(left):
|
|
1000
|
+
if index not in consumed_left and operation.semantic_component is not None:
|
|
1001
|
+
left_components.setdefault(operation.semantic_component, []).append(index)
|
|
1002
|
+
for index, operation in enumerate(right):
|
|
1003
|
+
if index not in consumed_right and operation.semantic_component is not None:
|
|
1004
|
+
right_components.setdefault(operation.semantic_component, []).append(index)
|
|
1005
|
+
for component in sorted(set(left_components) & set(right_components)):
|
|
1006
|
+
left_group = sorted(left_components[component])
|
|
1007
|
+
right_group = sorted(right_components[component])
|
|
1008
|
+
relations.append(
|
|
1009
|
+
PatchRelation(
|
|
1010
|
+
ThreeWayRelationKind.COMPATIBLE_SAME_COMPONENT,
|
|
1011
|
+
tuple(left[index] for index in left_group),
|
|
1012
|
+
tuple(right[index] for index in right_group),
|
|
1013
|
+
component,
|
|
1014
|
+
)
|
|
1015
|
+
)
|
|
1016
|
+
consumed_left.update(left_group)
|
|
1017
|
+
consumed_right.update(right_group)
|
|
1018
|
+
|
|
1019
|
+
for index, operation in enumerate(left):
|
|
1020
|
+
if index not in consumed_left:
|
|
1021
|
+
relations.append(
|
|
1022
|
+
PatchRelation(ThreeWayRelationKind.DISJOINT, (operation,), ())
|
|
1023
|
+
)
|
|
1024
|
+
for index, operation in enumerate(right):
|
|
1025
|
+
if index not in consumed_right:
|
|
1026
|
+
relations.append(
|
|
1027
|
+
PatchRelation(ThreeWayRelationKind.DISJOINT, (), (operation,))
|
|
1028
|
+
)
|
|
1029
|
+
return tuple(sorted(relations, key=_relation_sort_key))
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _relation_fingerprint(relation: PatchRelation) -> tuple[object, ...]:
|
|
1033
|
+
validate_patch_relation(relation)
|
|
1034
|
+
return (
|
|
1035
|
+
relation.kind.value,
|
|
1036
|
+
relation.semantic_component,
|
|
1037
|
+
tuple(operation_occurrence_bytes(value) for value in relation.left_operations),
|
|
1038
|
+
tuple(operation_occurrence_bytes(value) for value in relation.right_operations),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _assert_replayed_canonical_classification(
|
|
1043
|
+
ancestor: FrozenJsonValue,
|
|
1044
|
+
left_patch: TypedPatch,
|
|
1045
|
+
right_patch: TypedPatch,
|
|
1046
|
+
relations: tuple[PatchRelation, ...],
|
|
1047
|
+
) -> None:
|
|
1048
|
+
validate_typed_patch(left_patch)
|
|
1049
|
+
validate_typed_patch(right_patch)
|
|
1050
|
+
if type(relations) is not tuple:
|
|
1051
|
+
raise TypeError("relations must be an exact tuple")
|
|
1052
|
+
for relation in relations:
|
|
1053
|
+
validate_patch_relation(relation)
|
|
1054
|
+
if left_patch.limits != right_patch.limits:
|
|
1055
|
+
raise ValueError("three-way branch patches must share exact algebra limits")
|
|
1056
|
+
if (
|
|
1057
|
+
left_patch.base_candidate_id != right_patch.base_candidate_id
|
|
1058
|
+
or left_patch.base_hash != right_patch.base_hash
|
|
1059
|
+
):
|
|
1060
|
+
raise ValueError("three-way branch patches must share the exact ancestor")
|
|
1061
|
+
if left_patch.target_candidate_id == right_patch.target_candidate_id:
|
|
1062
|
+
raise ValueError("three-way branches must target distinct occurrences")
|
|
1063
|
+
apply_patch(ancestor, left_patch)
|
|
1064
|
+
apply_patch(ancestor, right_patch)
|
|
1065
|
+
expected = _derive_three_way_relations(left_patch, right_patch)
|
|
1066
|
+
if tuple(map(_relation_fingerprint, relations)) != tuple(
|
|
1067
|
+
map(_relation_fingerprint, expected)
|
|
1068
|
+
):
|
|
1069
|
+
raise ValueError(
|
|
1070
|
+
"relations are not the canonical global classification of the replayed patches"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
1075
|
+
class ThreeWayPatchClassification:
|
|
1076
|
+
ancestor: FrozenJsonValue
|
|
1077
|
+
ancestor_candidate_id: CandidateId
|
|
1078
|
+
ancestor_hash: str
|
|
1079
|
+
left_patch_hash: str
|
|
1080
|
+
right_patch_hash: str
|
|
1081
|
+
relations: tuple[PatchRelation, ...]
|
|
1082
|
+
left_patch: TypedPatch
|
|
1083
|
+
right_patch: TypedPatch
|
|
1084
|
+
|
|
1085
|
+
def __post_init__(self) -> None:
|
|
1086
|
+
if type(self.left_patch) is not TypedPatch or type(
|
|
1087
|
+
self.right_patch
|
|
1088
|
+
) is not TypedPatch:
|
|
1089
|
+
raise TypeError("classification patches must be exact TypedPatch values")
|
|
1090
|
+
validate_typed_patch(self.left_patch)
|
|
1091
|
+
validate_typed_patch(self.right_patch)
|
|
1092
|
+
frozen_ancestor = freeze_json(
|
|
1093
|
+
self.ancestor,
|
|
1094
|
+
limits=self.left_patch.limits.json_limits,
|
|
1095
|
+
)
|
|
1096
|
+
object.__setattr__(self, "ancestor", frozen_ancestor)
|
|
1097
|
+
if type(self.ancestor_candidate_id) is not CandidateId:
|
|
1098
|
+
raise TypeError("ancestor_candidate_id must be an exact CandidateId")
|
|
1099
|
+
CandidateId.__post_init__(self.ancestor_candidate_id)
|
|
1100
|
+
for value, name in (
|
|
1101
|
+
(self.ancestor_hash, "ancestor_hash"),
|
|
1102
|
+
(self.left_patch_hash, "left_patch_hash"),
|
|
1103
|
+
(self.right_patch_hash, "right_patch_hash"),
|
|
1104
|
+
):
|
|
1105
|
+
require_sha256(value, name)
|
|
1106
|
+
if (
|
|
1107
|
+
self.left_patch.base_candidate_id != self.ancestor_candidate_id
|
|
1108
|
+
or self.right_patch.base_candidate_id != self.ancestor_candidate_id
|
|
1109
|
+
or self.left_patch.base_hash != self.ancestor_hash
|
|
1110
|
+
or self.right_patch.base_hash != self.ancestor_hash
|
|
1111
|
+
or self.left_patch.patch_hash != self.left_patch_hash
|
|
1112
|
+
or self.right_patch.patch_hash != self.right_patch_hash
|
|
1113
|
+
):
|
|
1114
|
+
raise ValueError("classification endpoint or patch hashes do not bind")
|
|
1115
|
+
if type(self.relations) is not tuple:
|
|
1116
|
+
raise TypeError("relations must be an exact tuple of PatchRelation values")
|
|
1117
|
+
relation_bound = len(self.left_patch.operations) + len(
|
|
1118
|
+
self.right_patch.operations
|
|
1119
|
+
)
|
|
1120
|
+
if len(self.relations) > relation_bound:
|
|
1121
|
+
raise ValueError("relations exceeds the branch-operation bound")
|
|
1122
|
+
if any(type(relation) is not PatchRelation for relation in self.relations):
|
|
1123
|
+
raise TypeError("relations must be an exact tuple of PatchRelation values")
|
|
1124
|
+
for relation in self.relations:
|
|
1125
|
+
validate_patch_relation(relation)
|
|
1126
|
+
if self.relations != tuple(sorted(self.relations, key=_relation_sort_key)):
|
|
1127
|
+
raise ValueError("relations must use canonical order")
|
|
1128
|
+
if len({relation.relation_id for relation in self.relations}) != len(
|
|
1129
|
+
self.relations
|
|
1130
|
+
):
|
|
1131
|
+
raise ValueError("classification contains duplicate relation identities")
|
|
1132
|
+
|
|
1133
|
+
expected: dict[tuple[str, bytes], int] = {}
|
|
1134
|
+
for side, patch in (("left", self.left_patch), ("right", self.right_patch)):
|
|
1135
|
+
for operation in patch.operations:
|
|
1136
|
+
key = (side, operation_occurrence_bytes(operation))
|
|
1137
|
+
expected[key] = expected.get(key, 0) + 1
|
|
1138
|
+
observed: dict[tuple[str, bytes], int] = {}
|
|
1139
|
+
for relation in self.relations:
|
|
1140
|
+
for side, operations in (
|
|
1141
|
+
("left", relation.left_operations),
|
|
1142
|
+
("right", relation.right_operations),
|
|
1143
|
+
):
|
|
1144
|
+
for operation in operations:
|
|
1145
|
+
if operation.source_candidate_id != self.ancestor_candidate_id:
|
|
1146
|
+
raise ValueError(
|
|
1147
|
+
"relation operation does not bind the common ancestor"
|
|
1148
|
+
)
|
|
1149
|
+
key = (side, operation_occurrence_bytes(operation))
|
|
1150
|
+
observed[key] = observed.get(key, 0) + 1
|
|
1151
|
+
if observed != expected:
|
|
1152
|
+
raise ValueError("relations do not partition the two branch patches exactly")
|
|
1153
|
+
_assert_replayed_canonical_classification(
|
|
1154
|
+
self.ancestor,
|
|
1155
|
+
self.left_patch,
|
|
1156
|
+
self.right_patch,
|
|
1157
|
+
self.relations,
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
def revalidate(self) -> None:
|
|
1161
|
+
"""Replay both branches and recompute the complete global partition."""
|
|
1162
|
+
|
|
1163
|
+
if type(self) is not ThreeWayPatchClassification:
|
|
1164
|
+
raise TypeError("classification must be exact ThreeWayPatchClassification")
|
|
1165
|
+
ThreeWayPatchClassification.__post_init__(self)
|
|
1166
|
+
|
|
1167
|
+
def __eq__(self, other: object) -> bool:
|
|
1168
|
+
if (
|
|
1169
|
+
type(self) is not ThreeWayPatchClassification
|
|
1170
|
+
or type(other) is not ThreeWayPatchClassification
|
|
1171
|
+
):
|
|
1172
|
+
return False
|
|
1173
|
+
self.revalidate()
|
|
1174
|
+
other.revalidate()
|
|
1175
|
+
left = (
|
|
1176
|
+
canonical_typed_json_bytes(
|
|
1177
|
+
self.ancestor,
|
|
1178
|
+
limits=self.left_patch.limits.json_limits,
|
|
1179
|
+
),
|
|
1180
|
+
self.ancestor_candidate_id.value,
|
|
1181
|
+
self.ancestor_hash,
|
|
1182
|
+
self.left_patch_hash,
|
|
1183
|
+
self.right_patch_hash,
|
|
1184
|
+
tuple(relation.relation_id for relation in self.relations),
|
|
1185
|
+
)
|
|
1186
|
+
right = (
|
|
1187
|
+
canonical_typed_json_bytes(
|
|
1188
|
+
other.ancestor,
|
|
1189
|
+
limits=other.left_patch.limits.json_limits,
|
|
1190
|
+
),
|
|
1191
|
+
other.ancestor_candidate_id.value,
|
|
1192
|
+
other.ancestor_hash,
|
|
1193
|
+
other.left_patch_hash,
|
|
1194
|
+
other.right_patch_hash,
|
|
1195
|
+
tuple(relation.relation_id for relation in other.relations),
|
|
1196
|
+
)
|
|
1197
|
+
return left == right
|
|
1198
|
+
|
|
1199
|
+
__hash__ = None
|
|
1200
|
+
|
|
1201
|
+
def of_kind(self, kind: ThreeWayRelationKind) -> tuple[PatchRelation, ...]:
|
|
1202
|
+
if type(kind) is not ThreeWayRelationKind:
|
|
1203
|
+
raise TypeError("kind must be a ThreeWayRelationKind")
|
|
1204
|
+
self.revalidate()
|
|
1205
|
+
return tuple(relation for relation in self.relations if relation.kind is kind)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def classify_three_way_patches(
|
|
1209
|
+
ancestor: object,
|
|
1210
|
+
left_patch: TypedPatch,
|
|
1211
|
+
right_patch: TypedPatch,
|
|
1212
|
+
) -> ThreeWayPatchClassification:
|
|
1213
|
+
"""Verify and partition two ancestor-derived patches without merging."""
|
|
1214
|
+
|
|
1215
|
+
validate_typed_patch(left_patch)
|
|
1216
|
+
validate_typed_patch(right_patch)
|
|
1217
|
+
if (
|
|
1218
|
+
left_patch.base_candidate_id != right_patch.base_candidate_id
|
|
1219
|
+
or left_patch.base_hash != right_patch.base_hash
|
|
1220
|
+
):
|
|
1221
|
+
raise ValueError("three-way branch patches must share the exact ancestor")
|
|
1222
|
+
if left_patch.limits != right_patch.limits:
|
|
1223
|
+
raise ValueError("three-way branch patches must share exact algebra limits")
|
|
1224
|
+
if left_patch.target_candidate_id == right_patch.target_candidate_id:
|
|
1225
|
+
raise ValueError("three-way branches must target distinct occurrences")
|
|
1226
|
+
frozen_ancestor = freeze_json(ancestor, limits=left_patch.limits.json_limits)
|
|
1227
|
+
ordered = _derive_three_way_relations(left_patch, right_patch)
|
|
1228
|
+
return ThreeWayPatchClassification(
|
|
1229
|
+
ancestor=frozen_ancestor,
|
|
1230
|
+
ancestor_candidate_id=left_patch.base_candidate_id,
|
|
1231
|
+
ancestor_hash=left_patch.base_hash,
|
|
1232
|
+
left_patch_hash=left_patch.patch_hash,
|
|
1233
|
+
right_patch_hash=right_patch.patch_hash,
|
|
1234
|
+
relations=ordered,
|
|
1235
|
+
left_patch=left_patch,
|
|
1236
|
+
right_patch=right_patch,
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
1241
|
+
class PreservationObligationRequest:
|
|
1242
|
+
"""One requested path within a replay-verified three-way relation effect."""
|
|
1243
|
+
|
|
1244
|
+
relation_id: str
|
|
1245
|
+
source: PreservationSource
|
|
1246
|
+
path: JsonPath
|
|
1247
|
+
|
|
1248
|
+
def __post_init__(self) -> None:
|
|
1249
|
+
require_sha256(self.relation_id, "relation_id")
|
|
1250
|
+
if type(self.source) is not PreservationSource:
|
|
1251
|
+
raise TypeError("source must be a PreservationSource")
|
|
1252
|
+
validate_json_path(self.path)
|
|
1253
|
+
|
|
1254
|
+
def __eq__(self, other: object) -> bool:
|
|
1255
|
+
if (
|
|
1256
|
+
type(self) is not PreservationObligationRequest
|
|
1257
|
+
or type(other) is not PreservationObligationRequest
|
|
1258
|
+
):
|
|
1259
|
+
return False
|
|
1260
|
+
PreservationObligationRequest.__post_init__(self)
|
|
1261
|
+
PreservationObligationRequest.__post_init__(other)
|
|
1262
|
+
return (
|
|
1263
|
+
self.relation_id,
|
|
1264
|
+
self.source.value,
|
|
1265
|
+
canonical_path_bytes(self.path),
|
|
1266
|
+
) == (
|
|
1267
|
+
other.relation_id,
|
|
1268
|
+
other.source.value,
|
|
1269
|
+
canonical_path_bytes(other.path),
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
__hash__ = None
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
_ABSENCE_CONTEXT_SHAPE_DOMAIN = b"agent-evolve:absence-context-shape:v1\x00"
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _context_shape_hash(value: FrozenJsonValue) -> str:
|
|
1279
|
+
digest = hashlib.sha256()
|
|
1280
|
+
digest.update(_ABSENCE_CONTEXT_SHAPE_DOMAIN)
|
|
1281
|
+
if type(value) is FrozenJsonObject:
|
|
1282
|
+
digest.update(b"object")
|
|
1283
|
+
digest.update(len(value.items).to_bytes(8, "big"))
|
|
1284
|
+
for key, _ in value.items:
|
|
1285
|
+
encoded = key.encode("utf-8", errors="strict")
|
|
1286
|
+
digest.update(len(encoded).to_bytes(8, "big"))
|
|
1287
|
+
digest.update(encoded)
|
|
1288
|
+
elif type(value) is FrozenJsonArray:
|
|
1289
|
+
digest.update(b"array")
|
|
1290
|
+
digest.update(len(value.items).to_bytes(8, "big"))
|
|
1291
|
+
else:
|
|
1292
|
+
raise TypeError("absence context must be an object or array")
|
|
1293
|
+
return digest.hexdigest()
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
@dataclass(frozen=True, slots=True)
|
|
1297
|
+
class _ObservedPathState:
|
|
1298
|
+
state: PreservationExpectation
|
|
1299
|
+
value: FrozenJsonValue | None
|
|
1300
|
+
value_hash: str | None
|
|
1301
|
+
absence_context_path: JsonPath | None = None
|
|
1302
|
+
absence_context_kind: AbsenceContextKind | None = None
|
|
1303
|
+
absence_context_shape_hash: str | None = None
|
|
1304
|
+
absence_failure_kind: AbsenceFailureKind | None = None
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _path_state(
|
|
1308
|
+
value: FrozenJsonValue,
|
|
1309
|
+
path: JsonPath,
|
|
1310
|
+
*,
|
|
1311
|
+
limits: TypedJsonLimits,
|
|
1312
|
+
) -> _ObservedPathState:
|
|
1313
|
+
validate_json_path(path)
|
|
1314
|
+
frozen = freeze_json(value, limits=limits)
|
|
1315
|
+
if frozen is not value:
|
|
1316
|
+
raise TypeError("path-state inspection requires a frozen typed-JSON value")
|
|
1317
|
+
current = frozen
|
|
1318
|
+
traversed: list[ObjectKey | ArrayIndex] = []
|
|
1319
|
+
for segment in path.segments:
|
|
1320
|
+
context_path = JsonPath(tuple(traversed))
|
|
1321
|
+
if type(segment) is ObjectKey:
|
|
1322
|
+
if type(current) is not FrozenJsonObject:
|
|
1323
|
+
return _ObservedPathState(PreservationExpectation.ABSENT, None, None)
|
|
1324
|
+
found = False
|
|
1325
|
+
for key, item in current.items:
|
|
1326
|
+
if key == segment.value:
|
|
1327
|
+
current = item
|
|
1328
|
+
found = True
|
|
1329
|
+
break
|
|
1330
|
+
if not found:
|
|
1331
|
+
return _ObservedPathState(
|
|
1332
|
+
PreservationExpectation.ABSENT,
|
|
1333
|
+
None,
|
|
1334
|
+
None,
|
|
1335
|
+
context_path,
|
|
1336
|
+
AbsenceContextKind.OBJECT,
|
|
1337
|
+
_context_shape_hash(current),
|
|
1338
|
+
AbsenceFailureKind.MISSING_OBJECT_KEY,
|
|
1339
|
+
)
|
|
1340
|
+
elif type(segment) is ArrayIndex:
|
|
1341
|
+
if type(current) is not FrozenJsonArray:
|
|
1342
|
+
return _ObservedPathState(PreservationExpectation.ABSENT, None, None)
|
|
1343
|
+
if segment.value >= len(current.items):
|
|
1344
|
+
return _ObservedPathState(
|
|
1345
|
+
PreservationExpectation.ABSENT,
|
|
1346
|
+
None,
|
|
1347
|
+
None,
|
|
1348
|
+
context_path,
|
|
1349
|
+
AbsenceContextKind.ARRAY,
|
|
1350
|
+
_context_shape_hash(current),
|
|
1351
|
+
AbsenceFailureKind.ARRAY_INDEX_OUT_OF_BOUNDS,
|
|
1352
|
+
)
|
|
1353
|
+
current = current.items[segment.value]
|
|
1354
|
+
else: # pragma: no cover - validate_json_path closes the union.
|
|
1355
|
+
raise AssertionError("unsupported path segment")
|
|
1356
|
+
traversed.append(segment)
|
|
1357
|
+
return _ObservedPathState(
|
|
1358
|
+
PreservationExpectation.PRESENT,
|
|
1359
|
+
current,
|
|
1360
|
+
typed_json_sha256(current, limits=limits),
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _state_identity(
|
|
1365
|
+
state: _ObservedPathState,
|
|
1366
|
+
) -> tuple[
|
|
1367
|
+
PreservationExpectation,
|
|
1368
|
+
str | None,
|
|
1369
|
+
JsonPath | None,
|
|
1370
|
+
AbsenceContextKind | None,
|
|
1371
|
+
str | None,
|
|
1372
|
+
AbsenceFailureKind | None,
|
|
1373
|
+
]:
|
|
1374
|
+
return (
|
|
1375
|
+
state.state,
|
|
1376
|
+
state.value_hash,
|
|
1377
|
+
state.absence_context_path,
|
|
1378
|
+
state.absence_context_kind,
|
|
1379
|
+
state.absence_context_shape_hash,
|
|
1380
|
+
state.absence_failure_kind,
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _states_equal(
|
|
1385
|
+
left: _ObservedPathState,
|
|
1386
|
+
right: _ObservedPathState,
|
|
1387
|
+
*,
|
|
1388
|
+
limits: TypedJsonLimits,
|
|
1389
|
+
) -> bool:
|
|
1390
|
+
if left.state is not right.state:
|
|
1391
|
+
return False
|
|
1392
|
+
if left.state is PreservationExpectation.ABSENT:
|
|
1393
|
+
return True
|
|
1394
|
+
if left.value is None or right.value is None: # pragma: no cover - state invariant.
|
|
1395
|
+
raise AssertionError("present state lacked a typed value")
|
|
1396
|
+
return typed_json_equal(left.value, right.value, limits=limits)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _operation_prefix_index(
|
|
1400
|
+
operations: tuple[PatchOperation, ...],
|
|
1401
|
+
) -> dict[object, object]:
|
|
1402
|
+
terminal = object()
|
|
1403
|
+
root: dict[object, object] = {"__terminal_marker__": terminal}
|
|
1404
|
+
for operation in operations:
|
|
1405
|
+
node = root
|
|
1406
|
+
for segment in operation.path.segments:
|
|
1407
|
+
child = node.setdefault(segment, {})
|
|
1408
|
+
if type(child) is not dict: # pragma: no cover - prefix-free invariant.
|
|
1409
|
+
raise AssertionError("invalid operation-prefix trie")
|
|
1410
|
+
node = child
|
|
1411
|
+
node[terminal] = operation
|
|
1412
|
+
return root
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _covering_effect(
|
|
1416
|
+
index: dict[object, object],
|
|
1417
|
+
path: JsonPath,
|
|
1418
|
+
) -> PatchOperation:
|
|
1419
|
+
terminal = index["__terminal_marker__"]
|
|
1420
|
+
node = index
|
|
1421
|
+
match: PatchOperation | None = None
|
|
1422
|
+
for segment in path.segments:
|
|
1423
|
+
candidate = node.get(terminal)
|
|
1424
|
+
if type(candidate) in (
|
|
1425
|
+
ReplaceScalar,
|
|
1426
|
+
ReplaceSubtree,
|
|
1427
|
+
InsertSequenceItem,
|
|
1428
|
+
DeleteSequenceItem,
|
|
1429
|
+
PermuteSequence,
|
|
1430
|
+
):
|
|
1431
|
+
match = candidate
|
|
1432
|
+
break
|
|
1433
|
+
child = node.get(segment)
|
|
1434
|
+
if type(child) is not dict:
|
|
1435
|
+
break
|
|
1436
|
+
node = child
|
|
1437
|
+
else:
|
|
1438
|
+
candidate = node.get(terminal)
|
|
1439
|
+
if type(candidate) in (
|
|
1440
|
+
ReplaceScalar,
|
|
1441
|
+
ReplaceSubtree,
|
|
1442
|
+
InsertSequenceItem,
|
|
1443
|
+
DeleteSequenceItem,
|
|
1444
|
+
PermuteSequence,
|
|
1445
|
+
):
|
|
1446
|
+
match = candidate
|
|
1447
|
+
if match is None:
|
|
1448
|
+
raise ValueError(
|
|
1449
|
+
"obligation path must lie within exactly one source-branch operation effect"
|
|
1450
|
+
)
|
|
1451
|
+
return match
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def derive_preservation_obligations(
|
|
1455
|
+
classification: ThreeWayPatchClassification,
|
|
1456
|
+
requests: tuple[PreservationObligationRequest, ...],
|
|
1457
|
+
) -> tuple[PreservationObligation, ...]:
|
|
1458
|
+
"""Derive immutable obligations only from replayed branch innovations.
|
|
1459
|
+
|
|
1460
|
+
A branch-specific request must identify a value/presence change that differs
|
|
1461
|
+
from both the common ancestor and the other branch. An identical edit may
|
|
1462
|
+
be requested only once as neutral evidence and never yields parent credit.
|
|
1463
|
+
"""
|
|
1464
|
+
|
|
1465
|
+
if type(classification) is not ThreeWayPatchClassification:
|
|
1466
|
+
raise TypeError("classification must be exact ThreeWayPatchClassification")
|
|
1467
|
+
classification.revalidate()
|
|
1468
|
+
if type(requests) is not tuple:
|
|
1469
|
+
raise TypeError(
|
|
1470
|
+
"requests must be an exact tuple of PreservationObligationRequest values"
|
|
1471
|
+
)
|
|
1472
|
+
if len(requests) > MAX_PRESERVATION_OBLIGATIONS:
|
|
1473
|
+
raise ValueError("requests exceeds MAX_PRESERVATION_OBLIGATIONS")
|
|
1474
|
+
if any(type(request) is not PreservationObligationRequest for request in requests):
|
|
1475
|
+
raise TypeError(
|
|
1476
|
+
"requests must be an exact tuple of PreservationObligationRequest values"
|
|
1477
|
+
)
|
|
1478
|
+
for request in requests:
|
|
1479
|
+
PreservationObligationRequest.__post_init__(request)
|
|
1480
|
+
request_keys = tuple(
|
|
1481
|
+
(
|
|
1482
|
+
request.relation_id,
|
|
1483
|
+
request.source.value,
|
|
1484
|
+
canonical_path_bytes(request.path),
|
|
1485
|
+
)
|
|
1486
|
+
for request in requests
|
|
1487
|
+
)
|
|
1488
|
+
if len(set(request_keys)) != len(request_keys):
|
|
1489
|
+
raise ValueError("preservation requests cannot duplicate an exact source/path")
|
|
1490
|
+
|
|
1491
|
+
relations = {relation.relation_id: relation for relation in classification.relations}
|
|
1492
|
+
relation_indexes = {
|
|
1493
|
+
relation.relation_id: (
|
|
1494
|
+
_operation_prefix_index(relation.left_operations),
|
|
1495
|
+
_operation_prefix_index(relation.right_operations),
|
|
1496
|
+
)
|
|
1497
|
+
for relation in classification.relations
|
|
1498
|
+
}
|
|
1499
|
+
limits = classification.left_patch.limits.json_limits
|
|
1500
|
+
left_target = apply_patch(classification.ancestor, classification.left_patch)
|
|
1501
|
+
right_target = apply_patch(classification.ancestor, classification.right_patch)
|
|
1502
|
+
obligations: list[PreservationObligation] = []
|
|
1503
|
+
|
|
1504
|
+
for request in requests:
|
|
1505
|
+
relation = relations.get(request.relation_id)
|
|
1506
|
+
if relation is None:
|
|
1507
|
+
raise ValueError("preservation request names an unknown relation identity")
|
|
1508
|
+
ancestor_state = _path_state(
|
|
1509
|
+
classification.ancestor,
|
|
1510
|
+
request.path,
|
|
1511
|
+
limits=limits,
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
if request.source is PreservationSource.IDENTICAL_NEUTRAL:
|
|
1515
|
+
if relation.kind is not ThreeWayRelationKind.IDENTICAL:
|
|
1516
|
+
raise ValueError("only an identical relation may create a neutral obligation")
|
|
1517
|
+
left_effect = _covering_effect(
|
|
1518
|
+
relation_indexes[relation.relation_id][0], request.path
|
|
1519
|
+
)
|
|
1520
|
+
right_effect = _covering_effect(
|
|
1521
|
+
relation_indexes[relation.relation_id][1], request.path
|
|
1522
|
+
)
|
|
1523
|
+
left_state = _path_state(left_target, request.path, limits=limits)
|
|
1524
|
+
right_state = _path_state(right_target, request.path, limits=limits)
|
|
1525
|
+
if not _states_equal(left_state, right_state, limits=limits):
|
|
1526
|
+
raise ValueError("identical relation branches disagree at obligation path")
|
|
1527
|
+
expected_state = left_state
|
|
1528
|
+
source_parent_ids = (
|
|
1529
|
+
classification.left_patch.target_candidate_id,
|
|
1530
|
+
classification.right_patch.target_candidate_id,
|
|
1531
|
+
)
|
|
1532
|
+
patch_hashes = (
|
|
1533
|
+
classification.left_patch_hash,
|
|
1534
|
+
classification.right_patch_hash,
|
|
1535
|
+
)
|
|
1536
|
+
effect_hashes = (
|
|
1537
|
+
operation_effect_sha256(left_effect, limits=limits),
|
|
1538
|
+
operation_effect_sha256(right_effect, limits=limits),
|
|
1539
|
+
)
|
|
1540
|
+
else:
|
|
1541
|
+
if relation.kind is ThreeWayRelationKind.IDENTICAL:
|
|
1542
|
+
raise ValueError("identical edits are neutral and cannot earn branch credit")
|
|
1543
|
+
if request.source is PreservationSource.LEFT_BRANCH:
|
|
1544
|
+
operation_index = relation_indexes[relation.relation_id][0]
|
|
1545
|
+
target = left_target
|
|
1546
|
+
other_target = right_target
|
|
1547
|
+
patch = classification.left_patch
|
|
1548
|
+
elif request.source is PreservationSource.RIGHT_BRANCH:
|
|
1549
|
+
operation_index = relation_indexes[relation.relation_id][1]
|
|
1550
|
+
target = right_target
|
|
1551
|
+
other_target = left_target
|
|
1552
|
+
patch = classification.right_patch
|
|
1553
|
+
else: # pragma: no cover - enum closes sources.
|
|
1554
|
+
raise AssertionError("unsupported preservation source")
|
|
1555
|
+
effect = _covering_effect(operation_index, request.path)
|
|
1556
|
+
expected_state = _path_state(target, request.path, limits=limits)
|
|
1557
|
+
other_state = _path_state(other_target, request.path, limits=limits)
|
|
1558
|
+
if _states_equal(expected_state, ancestor_state, limits=limits):
|
|
1559
|
+
raise ValueError(
|
|
1560
|
+
"branch-specific obligation is not an actual change from the ancestor"
|
|
1561
|
+
)
|
|
1562
|
+
if _states_equal(expected_state, other_state, limits=limits):
|
|
1563
|
+
raise ValueError(
|
|
1564
|
+
"branch-specific obligation is not discriminative from the other branch"
|
|
1565
|
+
)
|
|
1566
|
+
source_parent_ids = (patch.target_candidate_id,)
|
|
1567
|
+
patch_hashes = (patch.patch_hash,)
|
|
1568
|
+
effect_hashes = (operation_effect_sha256(effect, limits=limits),)
|
|
1569
|
+
|
|
1570
|
+
if (
|
|
1571
|
+
expected_state.state is PreservationExpectation.ABSENT
|
|
1572
|
+
and expected_state.absence_context_path is None
|
|
1573
|
+
):
|
|
1574
|
+
raise ValueError(
|
|
1575
|
+
"absence obligations require a missing key/index in a surviving container"
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
obligations.append(
|
|
1579
|
+
PreservationObligation(
|
|
1580
|
+
source=request.source,
|
|
1581
|
+
source_parent_candidate_ids=source_parent_ids,
|
|
1582
|
+
branch_patch_hashes=patch_hashes,
|
|
1583
|
+
operation_effect_hashes=effect_hashes,
|
|
1584
|
+
relation_id=relation.relation_id,
|
|
1585
|
+
path=request.path,
|
|
1586
|
+
expected_state=expected_state.state,
|
|
1587
|
+
expected_value_hash=expected_state.value_hash,
|
|
1588
|
+
ancestor_state=ancestor_state.state,
|
|
1589
|
+
ancestor_value_hash=ancestor_state.value_hash,
|
|
1590
|
+
absence_context_path=expected_state.absence_context_path,
|
|
1591
|
+
absence_context_kind=expected_state.absence_context_kind,
|
|
1592
|
+
absence_context_shape_hash=expected_state.absence_context_shape_hash,
|
|
1593
|
+
absence_failure_kind=expected_state.absence_failure_kind,
|
|
1594
|
+
)
|
|
1595
|
+
)
|
|
1596
|
+
ordered = tuple(sorted(obligations, key=lambda value: value.obligation_id))
|
|
1597
|
+
if len({value.obligation_id for value in ordered}) != len(ordered):
|
|
1598
|
+
raise ValueError("derived preservation obligations have duplicate identities")
|
|
1599
|
+
return ordered
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
class ResolutionChoice(str, Enum):
|
|
1603
|
+
CHOOSE_LEFT = "choose_left"
|
|
1604
|
+
CHOOSE_RIGHT = "choose_right"
|
|
1605
|
+
SYNTHESIZE = "synthesize"
|
|
1606
|
+
DROP_BOTH = "drop_both"
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
1610
|
+
class PatchResolution:
|
|
1611
|
+
relation_id: str
|
|
1612
|
+
choice: ResolutionChoice
|
|
1613
|
+
synthesized_result_hash: str | None = None
|
|
1614
|
+
|
|
1615
|
+
def __post_init__(self) -> None:
|
|
1616
|
+
require_sha256(self.relation_id, "relation_id")
|
|
1617
|
+
if type(self.choice) is not ResolutionChoice:
|
|
1618
|
+
raise TypeError("choice must be a ResolutionChoice")
|
|
1619
|
+
if self.choice is ResolutionChoice.SYNTHESIZE:
|
|
1620
|
+
if self.synthesized_result_hash is None:
|
|
1621
|
+
raise ValueError("synthesize resolution requires a result hash")
|
|
1622
|
+
require_sha256(
|
|
1623
|
+
self.synthesized_result_hash,
|
|
1624
|
+
"synthesized_result_hash",
|
|
1625
|
+
)
|
|
1626
|
+
elif self.synthesized_result_hash is not None:
|
|
1627
|
+
raise ValueError("only synthesize resolutions may carry a result hash")
|
|
1628
|
+
|
|
1629
|
+
def __eq__(self, other: object) -> bool:
|
|
1630
|
+
if type(self) is not PatchResolution or type(other) is not PatchResolution:
|
|
1631
|
+
return False
|
|
1632
|
+
PatchResolution.__post_init__(self)
|
|
1633
|
+
PatchResolution.__post_init__(other)
|
|
1634
|
+
return (
|
|
1635
|
+
self.relation_id,
|
|
1636
|
+
self.choice.value,
|
|
1637
|
+
self.synthesized_result_hash,
|
|
1638
|
+
) == (
|
|
1639
|
+
other.relation_id,
|
|
1640
|
+
other.choice.value,
|
|
1641
|
+
other.synthesized_result_hash,
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
__hash__ = None
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def validate_three_way_resolutions(
|
|
1648
|
+
classification: ThreeWayPatchClassification,
|
|
1649
|
+
resolutions: tuple[PatchResolution, ...],
|
|
1650
|
+
) -> tuple[PatchResolution, ...]:
|
|
1651
|
+
"""Require exactly one declared choice for every conflict/invalidation."""
|
|
1652
|
+
|
|
1653
|
+
if type(classification) is not ThreeWayPatchClassification:
|
|
1654
|
+
raise TypeError("classification must be exact ThreeWayPatchClassification")
|
|
1655
|
+
classification.revalidate()
|
|
1656
|
+
if type(resolutions) is not tuple:
|
|
1657
|
+
raise TypeError("resolutions must be an exact tuple of PatchResolution values")
|
|
1658
|
+
if len(resolutions) > len(classification.left_patch.operations) + len(
|
|
1659
|
+
classification.right_patch.operations
|
|
1660
|
+
):
|
|
1661
|
+
raise ValueError("resolutions exceeds the branch-operation bound")
|
|
1662
|
+
if any(type(resolution) is not PatchResolution for resolution in resolutions):
|
|
1663
|
+
raise TypeError("resolutions must be an exact tuple of PatchResolution values")
|
|
1664
|
+
for resolution in resolutions:
|
|
1665
|
+
PatchResolution.__post_init__(resolution)
|
|
1666
|
+
if resolutions != tuple(sorted(resolutions, key=lambda value: value.relation_id)):
|
|
1667
|
+
raise ValueError("resolutions must use canonical relation-id order")
|
|
1668
|
+
ids = tuple(resolution.relation_id for resolution in resolutions)
|
|
1669
|
+
if len(set(ids)) != len(ids):
|
|
1670
|
+
raise ValueError("duplicate or conflicting resolutions are prohibited")
|
|
1671
|
+
required = {
|
|
1672
|
+
relation.relation_id
|
|
1673
|
+
for relation in classification.relations
|
|
1674
|
+
if relation.kind
|
|
1675
|
+
in (ThreeWayRelationKind.CONFLICT, ThreeWayRelationKind.INVALIDATED)
|
|
1676
|
+
}
|
|
1677
|
+
supplied = set(ids)
|
|
1678
|
+
if supplied != required:
|
|
1679
|
+
missing = required - supplied
|
|
1680
|
+
unknown = supplied - required
|
|
1681
|
+
reason = "missing" if missing else "unknown"
|
|
1682
|
+
raise ValueError(f"resolution set contains {reason} relation identities")
|
|
1683
|
+
return resolutions
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
1687
|
+
class ParentConfiguration:
|
|
1688
|
+
occurrence: CandidateOccurrence
|
|
1689
|
+
configuration: FrozenJsonValue
|
|
1690
|
+
|
|
1691
|
+
def __post_init__(self) -> None:
|
|
1692
|
+
if type(self.occurrence) is not CandidateOccurrence:
|
|
1693
|
+
raise TypeError("occurrence must be an exact CandidateOccurrence")
|
|
1694
|
+
CandidateOccurrence.__post_init__(self.occurrence)
|
|
1695
|
+
frozen = freeze_json(self.configuration)
|
|
1696
|
+
if frozen is not self.configuration:
|
|
1697
|
+
raise TypeError("configuration must already be a frozen typed-JSON value")
|
|
1698
|
+
if typed_json_sha256(frozen) != self.occurrence.configuration_hash:
|
|
1699
|
+
raise ValueError("configuration does not match its occurrence hash")
|
|
1700
|
+
|
|
1701
|
+
def __eq__(self, other: object) -> bool:
|
|
1702
|
+
if type(self) is not ParentConfiguration or type(other) is not ParentConfiguration:
|
|
1703
|
+
return False
|
|
1704
|
+
ParentConfiguration.__post_init__(self)
|
|
1705
|
+
ParentConfiguration.__post_init__(other)
|
|
1706
|
+
return (
|
|
1707
|
+
self.occurrence._validated_values(),
|
|
1708
|
+
canonical_typed_json_bytes(self.configuration),
|
|
1709
|
+
) == (
|
|
1710
|
+
other.occurrence._validated_values(),
|
|
1711
|
+
canonical_typed_json_bytes(other.configuration),
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
__hash__ = None
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def bind_parent_configuration(
|
|
1718
|
+
occurrence: CandidateOccurrence,
|
|
1719
|
+
configuration: object,
|
|
1720
|
+
*,
|
|
1721
|
+
limits: TypedJsonLimits,
|
|
1722
|
+
) -> ParentConfiguration:
|
|
1723
|
+
if type(occurrence) is not CandidateOccurrence:
|
|
1724
|
+
raise TypeError("occurrence must be an exact CandidateOccurrence")
|
|
1725
|
+
CandidateOccurrence.__post_init__(occurrence)
|
|
1726
|
+
frozen = freeze_json(configuration, limits=limits)
|
|
1727
|
+
if typed_json_sha256(frozen, limits=limits) != occurrence.configuration_hash:
|
|
1728
|
+
raise ValueError("configuration does not match its occurrence hash")
|
|
1729
|
+
return ParentConfiguration(occurrence, frozen)
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
1733
|
+
class PreservationVerification:
|
|
1734
|
+
child_hash: str
|
|
1735
|
+
verified_claims: tuple[PreservationClaim, ...]
|
|
1736
|
+
discriminatively_used_parent_ids: tuple[CandidateId, ...]
|
|
1737
|
+
|
|
1738
|
+
def __post_init__(self) -> None:
|
|
1739
|
+
require_sha256(self.child_hash, "child_hash")
|
|
1740
|
+
if type(self.verified_claims) is not tuple:
|
|
1741
|
+
raise TypeError("verified_claims must contain exact PreservationClaim values")
|
|
1742
|
+
if not 2 <= len(self.verified_claims) <= MAX_PRESERVATION_OBLIGATIONS:
|
|
1743
|
+
raise ValueError(
|
|
1744
|
+
"verified_claims must contain 2 to "
|
|
1745
|
+
f"{MAX_PRESERVATION_OBLIGATIONS} claims"
|
|
1746
|
+
)
|
|
1747
|
+
if any(type(claim) is not PreservationClaim for claim in self.verified_claims):
|
|
1748
|
+
raise TypeError("verified_claims must contain exact PreservationClaim values")
|
|
1749
|
+
for claim in self.verified_claims:
|
|
1750
|
+
PreservationClaim.__post_init__(claim)
|
|
1751
|
+
claim_ids = tuple(claim.obligation_id for claim in self.verified_claims)
|
|
1752
|
+
if claim_ids != tuple(sorted(claim_ids)):
|
|
1753
|
+
raise ValueError("verified_claims must use canonical obligation-id order")
|
|
1754
|
+
if len(set(claim_ids)) != len(claim_ids):
|
|
1755
|
+
raise ValueError("verified_claims cannot duplicate an obligation identity")
|
|
1756
|
+
if type(self.discriminatively_used_parent_ids) is not tuple:
|
|
1757
|
+
raise TypeError("used parent IDs must be an exact CandidateId tuple")
|
|
1758
|
+
if len(self.discriminatively_used_parent_ids) != 2:
|
|
1759
|
+
raise ValueError(
|
|
1760
|
+
"three-way preservation requires exactly two used parent IDs"
|
|
1761
|
+
)
|
|
1762
|
+
if any(
|
|
1763
|
+
type(value) is not CandidateId
|
|
1764
|
+
for value in self.discriminatively_used_parent_ids
|
|
1765
|
+
):
|
|
1766
|
+
raise TypeError("used parent IDs must be an exact CandidateId tuple")
|
|
1767
|
+
for value in self.discriminatively_used_parent_ids:
|
|
1768
|
+
CandidateId.__post_init__(value)
|
|
1769
|
+
if len(set(self.discriminatively_used_parent_ids)) != 2:
|
|
1770
|
+
raise ValueError("used parent IDs must be distinct")
|
|
1771
|
+
|
|
1772
|
+
def __eq__(self, other: object) -> bool:
|
|
1773
|
+
if (
|
|
1774
|
+
type(self) is not PreservationVerification
|
|
1775
|
+
or type(other) is not PreservationVerification
|
|
1776
|
+
):
|
|
1777
|
+
return False
|
|
1778
|
+
validate_preservation_verification(self)
|
|
1779
|
+
validate_preservation_verification(other)
|
|
1780
|
+
left = (
|
|
1781
|
+
self.child_hash,
|
|
1782
|
+
tuple(claim.obligation_id for claim in self.verified_claims),
|
|
1783
|
+
tuple(value.value for value in self.discriminatively_used_parent_ids),
|
|
1784
|
+
)
|
|
1785
|
+
right = (
|
|
1786
|
+
other.child_hash,
|
|
1787
|
+
tuple(claim.obligation_id for claim in other.verified_claims),
|
|
1788
|
+
tuple(value.value for value in other.discriminatively_used_parent_ids),
|
|
1789
|
+
)
|
|
1790
|
+
return left == right
|
|
1791
|
+
|
|
1792
|
+
__hash__ = None
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def validate_preservation_verification(value: PreservationVerification) -> None:
|
|
1796
|
+
"""Recursively validate one non-authoritative preservation receipt.
|
|
1797
|
+
|
|
1798
|
+
Structural validity does not prove that the receipt came from
|
|
1799
|
+
:func:`verify_preservation_claims`; a future durable evidence graph must
|
|
1800
|
+
bind it to the exact variation case, classification, child, and claims.
|
|
1801
|
+
"""
|
|
1802
|
+
|
|
1803
|
+
if type(value) is not PreservationVerification:
|
|
1804
|
+
raise TypeError("value must be an exact PreservationVerification")
|
|
1805
|
+
PreservationVerification.__post_init__(value)
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def verify_preservation_claims(
|
|
1809
|
+
variation_case: VariationCase,
|
|
1810
|
+
classification: ThreeWayPatchClassification,
|
|
1811
|
+
parent_configurations: tuple[ParentConfiguration, ...],
|
|
1812
|
+
child: object,
|
|
1813
|
+
*,
|
|
1814
|
+
claims: tuple[PreservationClaim, ...],
|
|
1815
|
+
limits: TypedJsonLimits,
|
|
1816
|
+
) -> PreservationVerification:
|
|
1817
|
+
"""Replay and verify exact predeclared three-way innovation obligations."""
|
|
1818
|
+
|
|
1819
|
+
validate_variation_case(variation_case)
|
|
1820
|
+
if variation_case.variation_kind is not VariationKind.THREE_WAY_RECOMBINATION:
|
|
1821
|
+
raise ValueError(
|
|
1822
|
+
"this preservation verifier accepts only verified three-way cases"
|
|
1823
|
+
)
|
|
1824
|
+
if type(classification) is not ThreeWayPatchClassification:
|
|
1825
|
+
raise TypeError("classification must be exact ThreeWayPatchClassification")
|
|
1826
|
+
classification.revalidate()
|
|
1827
|
+
if type(limits) is not TypedJsonLimits:
|
|
1828
|
+
raise TypeError("limits must be an exact TypedJsonLimits value")
|
|
1829
|
+
validate_typed_json_limits(limits)
|
|
1830
|
+
if limits != classification.left_patch.limits.json_limits:
|
|
1831
|
+
raise ValueError("verification limits must match the branch patch algebra")
|
|
1832
|
+
if variation_case.common_ancestor is None:
|
|
1833
|
+
raise ValueError("three-way preservation requires a common ancestor")
|
|
1834
|
+
if (
|
|
1835
|
+
classification.ancestor_candidate_id
|
|
1836
|
+
!= variation_case.common_ancestor.candidate_id
|
|
1837
|
+
or classification.ancestor_hash
|
|
1838
|
+
!= variation_case.common_ancestor.configuration_hash
|
|
1839
|
+
or tuple(
|
|
1840
|
+
patch.patch_hash for patch in variation_case.ancestor_to_parent_patches
|
|
1841
|
+
)
|
|
1842
|
+
!= (classification.left_patch_hash, classification.right_patch_hash)
|
|
1843
|
+
):
|
|
1844
|
+
raise ValueError("classification does not bind the exact variation case")
|
|
1845
|
+
if type(parent_configurations) is not tuple:
|
|
1846
|
+
raise TypeError(
|
|
1847
|
+
"parent_configurations must be an exact tuple of ParentConfiguration values"
|
|
1848
|
+
)
|
|
1849
|
+
if len(parent_configurations) != 2:
|
|
1850
|
+
raise ValueError("three-way preservation requires exactly two parent configurations")
|
|
1851
|
+
if any(type(value) is not ParentConfiguration for value in parent_configurations):
|
|
1852
|
+
raise TypeError(
|
|
1853
|
+
"parent_configurations must be an exact tuple of ParentConfiguration values"
|
|
1854
|
+
)
|
|
1855
|
+
for value in parent_configurations:
|
|
1856
|
+
ParentConfiguration.__post_init__(value)
|
|
1857
|
+
if type(claims) is not tuple:
|
|
1858
|
+
raise TypeError("claims must be an exact tuple of PreservationClaim values")
|
|
1859
|
+
if len(claims) > MAX_PRESERVATION_OBLIGATIONS:
|
|
1860
|
+
raise ValueError("claims exceeds MAX_PRESERVATION_OBLIGATIONS")
|
|
1861
|
+
if any(type(claim) is not PreservationClaim for claim in claims):
|
|
1862
|
+
raise TypeError("claims must be an exact tuple of PreservationClaim values")
|
|
1863
|
+
for claim in claims:
|
|
1864
|
+
PreservationClaim.__post_init__(claim)
|
|
1865
|
+
claim_ids = tuple(claim.obligation_id for claim in claims)
|
|
1866
|
+
if claim_ids != tuple(sorted(claim_ids)):
|
|
1867
|
+
raise ValueError("claims must use canonical obligation-id order")
|
|
1868
|
+
if len(set(claim_ids)) != len(claim_ids):
|
|
1869
|
+
raise ValueError("claims cannot duplicate an obligation identity")
|
|
1870
|
+
expected_ids = tuple(
|
|
1871
|
+
parent.occurrence.candidate_id for parent in variation_case.parents
|
|
1872
|
+
)
|
|
1873
|
+
actual_ids = tuple(value.occurrence.candidate_id for value in parent_configurations)
|
|
1874
|
+
if actual_ids != expected_ids:
|
|
1875
|
+
raise ValueError("parent configurations must follow exact variation-case order")
|
|
1876
|
+
for binding, value in zip(variation_case.parents, parent_configurations):
|
|
1877
|
+
if value.occurrence != binding.occurrence:
|
|
1878
|
+
raise ValueError("parent configuration occurrence does not match the case")
|
|
1879
|
+
replayed_targets = (
|
|
1880
|
+
apply_patch(classification.ancestor, classification.left_patch),
|
|
1881
|
+
apply_patch(classification.ancestor, classification.right_patch),
|
|
1882
|
+
)
|
|
1883
|
+
for supplied, replayed in zip(parent_configurations, replayed_targets):
|
|
1884
|
+
if not typed_json_equal(supplied.configuration, replayed, limits=limits):
|
|
1885
|
+
raise ValueError("parent configuration does not match its replayed branch patch")
|
|
1886
|
+
|
|
1887
|
+
requests = tuple(
|
|
1888
|
+
PreservationObligationRequest(
|
|
1889
|
+
obligation.relation_id,
|
|
1890
|
+
obligation.source,
|
|
1891
|
+
obligation.path,
|
|
1892
|
+
)
|
|
1893
|
+
for obligation in variation_case.preservation_obligations
|
|
1894
|
+
)
|
|
1895
|
+
rederived_obligations = derive_preservation_obligations(classification, requests)
|
|
1896
|
+
if rederived_obligations != variation_case.preservation_obligations:
|
|
1897
|
+
raise PreservationError(
|
|
1898
|
+
"predeclared obligations are not exact replay-derived branch effects"
|
|
1899
|
+
)
|
|
1900
|
+
child_value = freeze_json(child, limits=limits)
|
|
1901
|
+
by_id = {
|
|
1902
|
+
value.occurrence.candidate_id: value.configuration
|
|
1903
|
+
for value in parent_configurations
|
|
1904
|
+
}
|
|
1905
|
+
required_ids = expected_ids
|
|
1906
|
+
obligations = {
|
|
1907
|
+
obligation.obligation_id: obligation
|
|
1908
|
+
for obligation in variation_case.preservation_obligations
|
|
1909
|
+
}
|
|
1910
|
+
if set(claim_ids) != set(obligations):
|
|
1911
|
+
raise PreservationError(
|
|
1912
|
+
"preservation claims must cover every predeclared obligation exactly once"
|
|
1913
|
+
)
|
|
1914
|
+
discriminative: set[CandidateId] = set()
|
|
1915
|
+
for claim in claims:
|
|
1916
|
+
obligation = obligations.get(claim.obligation_id)
|
|
1917
|
+
if obligation is None: # pragma: no cover - exact set check above.
|
|
1918
|
+
raise PreservationError("claim does not name a predeclared obligation")
|
|
1919
|
+
child_state = _path_state(child_value, obligation.path, limits=limits)
|
|
1920
|
+
if _state_identity(child_state) != (
|
|
1921
|
+
obligation.expected_state,
|
|
1922
|
+
obligation.expected_value_hash,
|
|
1923
|
+
obligation.absence_context_path,
|
|
1924
|
+
obligation.absence_context_kind,
|
|
1925
|
+
obligation.absence_context_shape_hash,
|
|
1926
|
+
obligation.absence_failure_kind,
|
|
1927
|
+
):
|
|
1928
|
+
raise PreservationError(
|
|
1929
|
+
"child does not preserve the obligation's exact presence-aware state"
|
|
1930
|
+
)
|
|
1931
|
+
if obligation.expected_state is PreservationExpectation.PRESENT:
|
|
1932
|
+
source_parent = by_id[obligation.source_parent_candidate_ids[0]]
|
|
1933
|
+
expected_value = value_at_path(source_parent, obligation.path)
|
|
1934
|
+
if child_state.value is None or not typed_json_equal(
|
|
1935
|
+
child_state.value,
|
|
1936
|
+
expected_value,
|
|
1937
|
+
limits=limits,
|
|
1938
|
+
):
|
|
1939
|
+
raise PreservationError("child value does not match the replayed source branch")
|
|
1940
|
+
if obligation.source is not PreservationSource.IDENTICAL_NEUTRAL:
|
|
1941
|
+
discriminative.add(obligation.source_parent_candidate_ids[0])
|
|
1942
|
+
|
|
1943
|
+
if set(required_ids) != discriminative:
|
|
1944
|
+
raise PreservationError(
|
|
1945
|
+
"one or more branch parents have no verified innovation-preservation claim"
|
|
1946
|
+
)
|
|
1947
|
+
return PreservationVerification(
|
|
1948
|
+
child_hash=typed_json_sha256(child_value, limits=limits),
|
|
1949
|
+
verified_claims=claims,
|
|
1950
|
+
discriminatively_used_parent_ids=tuple(
|
|
1951
|
+
candidate_id for candidate_id in required_ids if candidate_id in discriminative
|
|
1952
|
+
),
|
|
1953
|
+
)
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
__all__ = [
|
|
1957
|
+
"ComponentTagAssignment",
|
|
1958
|
+
"ParentConfiguration",
|
|
1959
|
+
"PatchPostconditionError",
|
|
1960
|
+
"PatchPreconditionError",
|
|
1961
|
+
"PatchRelation",
|
|
1962
|
+
"PatchResolution",
|
|
1963
|
+
"PreservationObligationRequest",
|
|
1964
|
+
"PreservationError",
|
|
1965
|
+
"PreservationVerification",
|
|
1966
|
+
"ResolutionChoice",
|
|
1967
|
+
"ThreeWayPatchClassification",
|
|
1968
|
+
"ThreeWayRelationKind",
|
|
1969
|
+
"apply_patch",
|
|
1970
|
+
"bind_parent_configuration",
|
|
1971
|
+
"classify_three_way_patches",
|
|
1972
|
+
"derive_patch",
|
|
1973
|
+
"derive_preservation_obligations",
|
|
1974
|
+
"invert_patch",
|
|
1975
|
+
"replace_existing_path",
|
|
1976
|
+
"validate_three_way_resolutions",
|
|
1977
|
+
"validate_patch_relation",
|
|
1978
|
+
"validate_preservation_verification",
|
|
1979
|
+
"value_at_path",
|
|
1980
|
+
"verify_preservation_claims",
|
|
1981
|
+
]
|