diff --git a/README.md b/README.md index c348af1ae..d893220f3 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ Python client for HugeGraph operations: - [Project Homepage](https://hugegraph.apache.org/docs/quickstart/hugegraph-ai/) - [LLM Quick Start Guide](./hugegraph-llm/quick_start.md) +- [Experimental Extraction Runtime](./hugegraph-llm/docs/extraction-runtime.md) — replay example, concurrent chunks, and contribution boundaries - [DeepWiki AI Documentation](https://deepwiki.com/apache/hugegraph-ai) ## 🔗 Related HugeGraph Projects diff --git a/hugegraph-llm/MANIFEST.in b/hugegraph-llm/MANIFEST.in index 897d1306b..c3b9185e1 100644 --- a/hugegraph-llm/MANIFEST.in +++ b/hugegraph-llm/MANIFEST.in @@ -21,3 +21,4 @@ # Maintenance: When adding or removing resource files, update this file to keep the package contents accurate. recursive-include src/hugegraph_llm/resources * +recursive-include src/hugegraph_llm/extraction_runtime/resources *.json diff --git a/hugegraph-llm/README.md b/hugegraph-llm/README.md index aa61b2758..3de6747bc 100644 --- a/hugegraph-llm/README.md +++ b/hugegraph-llm/README.md @@ -14,6 +14,15 @@ HugeGraph-LLM is a comprehensive toolkit that combines the power of graph databa For detailed source code doc, visit our [DeepWiki](https://deepwiki.com/apache/hugegraph-ai) page. (Recommended) +### Experimental Extraction Runtime + +Contributors can experiment with a domain-neutral extract, review, and repair +lifecycle, including concurrent chunks. The prototype has a deterministic replay +example; live model integration and production routing remain separate work. +See the [runtime guide](docs/extraction-runtime.md) for setup, extension points, +and tests, and the [architecture explanation (中文)](docs/extraction-runtime-architecture.zh-CN.md) +for module responsibilities and extension boundaries. + ## 📋 Prerequisites > [!IMPORTANT] diff --git a/hugegraph-llm/docs/assets/extraction-runtime-review-loop.png b/hugegraph-llm/docs/assets/extraction-runtime-review-loop.png new file mode 100644 index 000000000..f6f326006 Binary files /dev/null and b/hugegraph-llm/docs/assets/extraction-runtime-review-loop.png differ diff --git a/hugegraph-llm/docs/extraction-runtime-architecture.zh-CN.md b/hugegraph-llm/docs/extraction-runtime-architecture.zh-CN.md new file mode 100644 index 000000000..f698c7689 --- /dev/null +++ b/hugegraph-llm/docs/extraction-runtime-architecture.zh-CN.md @@ -0,0 +1,57 @@ +# Extraction Runtime 架构 + +Extraction Runtime 是一个实验性的抽取执行框架,负责把准备好的文本块转换为经过校验和审阅的图结果。它提供统一的抽取、审阅与修复循环,并支持多个文本块并发运行;具体领域的图结构和质量标准由可替换的领域能力提供。 + +本文面向需要理解模块职责或扩展框架的贡献者。运行示例和测试方法见[使用说明](extraction-runtime.md)。下文将框架外负责文档和任务管理的应用称为“宿主”。 + +## 1. 框架围绕一次文本块抽取划分职责 + +当前框架位于 HugeGraph LLM 包内,以准备好的文本块、领域能力和运行预算为输入,返回图结果、结束状态和执行记录。文档读取与切分发生在进入框架之前,保存和使用图结果发生在框架返回之后。 + +| 模块 | 核心职责 | 与其他模块的协作 | +| --- | --- | --- | +| 执行引擎 | 推进阶段,控制审阅修复循环和预算 | 调用领域能力,更新图状态,组织运行结果 | +| 领域能力 | 定义抽取、结构与身份校验、审阅、修复和最终判定规则 | 接收当前图和上下文,返回阶段结果;按需调用模型 | +| 图状态 | 维护当前图快照和版本 | 接受完整修复图,为后续判定提供一致的图版本 | +| 模型交互 | 分开请求适配与实际执行 | 将领域请求转换为模型可接受的请求,再返回响应 | +| 结果与执行记录 | 绑定图、结束状态、预算、轨迹和指纹 | 向调用方交付内存数据,由宿主决定如何保存和使用 | +| 批量执行 | 组合多个独立文本块的运行 | 控制同时运行数量,按输入顺序汇总结果 | + +这种划分使领域扩展主要落在规则与模型交互上,流程推进和图版本管理由框架统一负责。[执行与领域边界](../src/hugegraph_llm/extraction_runtime/v1/engine.py) · [图状态](../src/hugegraph_llm/extraction_runtime/v1/graph_state.py) · [模型交互](../src/hugegraph_llm/extraction_runtime/provider/contracts.py) · [批量执行](../src/hugegraph_llm/extraction_runtime/v1/batch.py) + +## 2. 审阅和修复始终围绕当前图版本进行 + +一次运行依次经过抽取、图结构校验、实体身份校验、审阅和最终判定。结构校验检查领域图是否符合约定,身份校验检查实体标识是否一致,审阅判断质量是否达到要求,最终判定决定是否接受结果。 + +需要修复时,领域能力提交一份完整候选图,并指明它基于哪个旧版本生成。框架接受候选图后推进版本,再从结构校验开始执行。内核不负责应用局部图补丁;审阅和最终判定必须对应当前图,避免旧结论被用于新结果。[执行循环](../src/hugegraph_llm/extraction_runtime/v1/engine.py) · [图版本管理](../src/hugegraph_llm/extraction_runtime/v1/graph_state.py) + +![单文本块的审阅修复循环:抽取后进行结构校验、身份校验和审阅,通过后进入最终判定;需要修复时生成新图版本并重新校验。运行可结束为通过、候选、阻断或失败。](assets/extraction-runtime-review-loop.png) + +审阅次数和修复次数分别受预算约束。审阅按调用次数计数,修复在候选图成功替换当前图后计数;每个文本块使用独立预算。具体质量分数、问题台账或增量审阅策略由领域能力自行实现,框架不预设这些业务规则。[预算管理](../src/hugegraph_llm/extraction_runtime/v1/review_loop.py) + +## 3. 返回结果表达抽取结论与执行依据 + +| 结束状态 | 含义 | +| --- | --- | +| 通过 | 当前图通过结构、身份、审阅和最终判定 | +| 候选 | 结构与身份检查已通过,但质量预算耗尽或最终判定要求暂缓接受 | +| 阻断 | 必要校验在修复预算内未能通过,或审阅、最终判定明确拒绝接受 | +| 失败 | 执行或结果构造发生异常,可能没有可用图 | + +结束状态与对应图版本绑定。调用方还可以获得预算使用情况、执行轨迹、诊断信息、指纹和产物数据。框架返回这些数据时,不会自动保存文件或写入 HugeGraph;“通过”只表示抽取结果被接受。[终态规则](../src/hugegraph_llm/extraction_runtime/v1/terminal.py) · [产物组织](../src/hugegraph_llm/extraction_runtime/v1/artifacts.py) + +指纹标识本次运行依赖的条件,分别组合运行时、领域、模型、输入和可选任务计划。领域接入方声明提示词、图结构及规则的摘要,便于区分结果变化来自哪一层;框架不会自动扫描业务资源,声明需要与实际执行保持一致。[语义声明](../src/hugegraph_llm/extraction_runtime/v1/manifest.py) · [分层指纹](../src/hugegraph_llm/extraction_runtime/v1/fingerprint.py) + +## 4. 并发组合保持单块执行的边界 + +批量执行通过线程池限制同时运行的文本块数量。每个文本块创建独立的领域能力和运行参数,各自完成抽取循环,最后按输入顺序返回结果。某个文本块以失败状态结束时,其余文本块的结果仍会保留。[批量执行](../src/hugegraph_llm/extraction_runtime/v1/batch.py) + +当前批量接口面向有限批次,会整批提交任务并保留结果。它不合并跨块图、不执行跨块实体消歧,也不管理持久化恢复。准备领域能力或遍历输入时发生的错误会直接返回给调用方,区别于抽取过程产生的失败状态。使用约定见[并发说明](extraction-runtime.md#concurrent-chunks)。 + +## 5. 扩展从领域、模型和宿主三个位置接入 + +- **领域扩展**提供提示词、图结构、身份规则和质量策略,遵守统一阶段约定。当前库存领域示例展示了完整接入方式,可作为新领域的实现与测试参考。[库存领域示例](../src/hugegraph_llm/extraction_runtime/conformance/inventory.py) +- **模型扩展**适配请求能力并实现真实调用,将响应转换为领域图。目前已有确定性回放实现,真实模型客户端尚未接入本框架。[模型交互约定](../src/hugegraph_llm/extraction_runtime/provider/contracts.py) · [回放实现](../src/hugegraph_llm/extraction_runtime/provider/replay.py) +- **宿主扩展**准备文档和文本块,管理执行资源,并承接结果保存、任务恢复、跨块合并及发布。当前框架以程序调用方式供实验使用,尚未接入现有生产接口。 + +这些接口仍处于实验阶段。当前验证范围包括执行循环、修复图传播、终态、模型回放和并发独立性;真实模型抽取质量及完整应用集成需要另外验证。运行方法与扩展指引见[使用说明](extraction-runtime.md),现有验证行为见[运行时测试](../src/tests/extraction_runtime)。 diff --git a/hugegraph-llm/docs/extraction-runtime.md b/hugegraph-llm/docs/extraction-runtime.md new file mode 100644 index 000000000..897242026 --- /dev/null +++ b/hugegraph-llm/docs/extraction-runtime.md @@ -0,0 +1,191 @@ +# Experimental Extraction Runtime + +`hugegraph_llm.extraction_runtime` is a dormant, experimental internal subsystem +for domain-neutral extraction of already-normalized chunks. Its single-chunk +engine can also run in concurrent batches with independent per-chunk state. +It is not a replacement for `GraphExtractFlow`, a public extension API, or a +production route. + +For component responsibilities, the review/repair lifecycle, and extension boundaries, +see the [architecture explanation (中文)](extraction-runtime-architecture.zh-CN.md). + +## Boundary + +The versioned runtime executes this fixed lifecycle: + +```text +extract -> schema -> identity -> bounded review/fix -> final gate +``` + +The runtime owns the current immutable graph revision, business review/fix +budget accounting, trace, diagnostics, terminal resolution, layered +fingerprints, and an uncommitted terminal artifact body. A statically supplied +Bundle owns every domain-specific prompt, schema, identity rule, review/fix +policy, materializer, and final gate. + +The provider package defines credential-free neutral and effective request +contracts, capability adaptation records, and a deterministic ReplayProvider. +It does not modify or wrap the existing OpenAI, Ollama, or LiteLLM clients. + +## Experimental use + +Use a checkout containing this module and Python 3.10 or 3.11. From the repository +root, install the workspace and test dependencies: + +```bash +uv sync --python 3.11 --extra llm --extra dev +``` + +The replay example and runtime tests need neither a running HugeGraph Server nor +model credentials. They verify the execution protocol using fixed responses; +they do not measure extraction quality from a live model. + +Repository-internal experiments may import the versioned surface explicitly: + +```python +from hugegraph_llm.extraction_runtime.v1 import ExtractionEngineV1 +``` + +The Inventory Bundle in `hugegraph_llm.extraction_runtime.conformance` is a +deterministic inventory fixture that demonstrates the complete lifecycle. It is +not a stable Bundle API promise or a production domain. + +## Concurrent chunks + +`run_chunks_v1(chunks=..., prepare=..., max_workers=4)` runs a finite batch +through the existing single-chunk engine. `prepare(chunk)` returns a fresh +Bundle and its `RunControlV1`. It runs in a worker thread, so create a separate +stateful Provider for each chunk, and synchronize any external mutable state +shared by the factory itself. In particular, do not share a ReplayProvider's +transcript cursor between chunks. + +The worker limit covers preparation and the entire extract/review/fix lifecycle. +Budgets apply independently to each chunk. Results are a tuple of +`ChunkRunResultV1(chunk, result)` in input order, even if execution completes out +of order. Chunk ordinals do not reorder the results. An empty input returns an +empty tuple; `max_workers=1` runs serially. + +The batch preserves each engine result, including `failed`, `blocked`, and +`candidate`, so one extraction failure does not discard successful chunks. +Errors while preparing a Bundle or iterating the input propagate to the caller; +they do not become extraction artifacts. Running tasks finish and the thread +pool closes before the call returns or raises. A preparation error may cancel +tasks that have not started. + +This synchronous helper submits and retains the whole batch in memory. It does +not merge graphs or resolve identities across chunks. Concurrency does not +change a chunk's semantic fingerprint; deterministic replay produces the same +artifact when run serially or concurrently. + +After the setup above, save this example as `extraction_example.py` in the +repository root and run `uv run --no-sync python extraction_example.py`. +It uses two fixed replay outputs: + +```python +from hugegraph_llm.extraction_runtime.conformance import InventoryBundleV1 +from hugegraph_llm.extraction_runtime.provider import ( + ProviderResponseV1, + ReplayEntryV1, + ReplayProvider, +) +from hugegraph_llm.extraction_runtime.v1 import ( + NormalizedChunkV1, + ReviewBudgetV1, + RunControlV1, + run_chunks_v1, +) + + +def prepare(chunk): + template = InventoryBundleV1(ReplayProvider(())) + request = template.plan_extract(chunk) + entry = ReplayEntryV1( + requested_request_digest=request.adaptation.requested_digest, + effective_request_digest=request.adaptation.effective_digest, + response=ProviderResponseV1( + output={"graph": {"items": [{"sku": chunk.chunk_id, "count": 2}]}}, + model=template.model, + ), + ) + bundle = InventoryBundleV1(ReplayProvider((entry,))) + control = RunControlV1( + budget=ReviewBudgetV1(max_reviews=2, max_fixes=1), + provider_execution=bundle.provider_execution(), + ) + return bundle, control + + +chunks = [ + NormalizedChunkV1(document_id="stock", chunk_id="BOLT", ordinal=0, text="Two bolts."), + NormalizedChunkV1(document_id="stock", chunk_id="NUT", ordinal=1, text="Two nuts."), +] +for item in run_chunks_v1(chunks=chunks, prepare=prepare, max_workers=2): + print(item.chunk.chunk_id, item.result.intent.kind.value) +``` + +Expected output is `BOLT final` followed by `NUT final`. Replace `prepare` with +your own Bundle and Provider construction to use a different domain or model. + +## Reading the result + +Each chunk result includes its graph, terminal decision, budget usage, execution +trace, diagnostics, fingerprints, and an uncommitted artifact body. Check the +terminal decision before treating a graph as accepted: + +| Terminal | Meaning | +| --- | --- | +| final | The current graph passed validation, identity checks, review, and the final gate. | +| candidate | The graph passed structural and identity checks, but the quality budget ended or the final gate requested a hold. | +| blocked | Required graph checks could not be satisfied within the repair budget, or review or the final gate explicitly blocked acceptance. | +| failed | Execution or result construction failed; a graph may be absent. | + +None of these states implies a file was saved or a graph was written to HugeGraph. +The [terminal rules](../src/hugegraph_llm/extraction_runtime/v1/terminal.py) define +the precise mapping and reason codes. + +## Extending the prototype + +- **Add a domain:** implement the [Bundle contract](../src/hugegraph_llm/extraction_runtime/v1/engine.py), + using the [Inventory example](../src/hugegraph_llm/extraction_runtime/conformance/inventory.py) + as a reference. Supply the domain's prompts, graph and identity rules, review, + repair, and final decision. Declare the resources and rules in the semantic + manifest so their changes affect the run fingerprint. +- **Add a model connection:** implement the transport in the + [provider contracts](../src/hugegraph_llm/extraction_runtime/provider/contracts.py) + and adapt its output to the domain graph. The existing Inventory model name + and output format are replay fixtures, not a ready-to-use live provider setup. +- **Add application integration:** prepare normalized chunks and own storage, + recovery, cross-chunk merging, and publication outside the engine. Batch + preparation must keep stateful Bundle and Provider instances independent. + +Repairs return a complete replacement graph bound to the previous graph digest; +review and final decisions must refer to the current graph. Preserve these +relationships when implementing a domain. The +[conformance tests](../src/tests/extraction_runtime/test_inventory_conformance.py) +show a repair cycle, terminal outcomes, and invalid repair cases; use those +behaviors as a starting point for testing a new integration. + +## Local verification + +After setup, run the runtime suite from the repository root: + +```bash +uv run --no-sync pytest hugegraph-llm/src/tests/extraction_runtime -q +``` + +The suite covers the lifecycle, repaired graph propagation, terminal decisions, +provider adaptation and replay, concurrent state isolation, and separation from +production callers. A successful run ends with all selected tests passing. +These tests belong to the repository's [unit / pure contract layer](../../docs/quality/test-taxonomy.md). +Passing them verifies the prototype's contracts, not live-model quality or a +complete application integration. + +## Compatibility and rollback + +The package has no production caller. `GraphExtractFlow`, `/graph/extract`, the +fixed-flow scheduler, existing provider clients, and existing defaults remain +unchanged. There is no `/extraction-jobs` route, extraction CLI, durable host +commit, rollout switch, database migration, or HugeGraph write in this version. + +Rollback consists of removing the dormant package and its tests. No artifact, +database, or HugeGraph migration is required. diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/__init__.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/__init__.py new file mode 100644 index 000000000..24862dd28 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/__init__.py @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dormant experimental extraction runtime. + +The supported experimental contracts are versioned below this package. Nothing +is re-exported here so importing :mod:`hugegraph_llm` cannot activate the runtime. +""" diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/conformance/__init__.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/conformance/__init__.py new file mode 100644 index 000000000..64018fc5f --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/conformance/__init__.py @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic conformance fixtures for the experimental runtime.""" + +from hugegraph_llm.extraction_runtime.conformance.inventory import InventoryBundleV1, InventoryPolicyV1 + +__all__ = ["InventoryBundleV1", "InventoryPolicyV1"] diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/conformance/inventory.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/conformance/inventory.py new file mode 100644 index 000000000..a55bb1fb0 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/conformance/inventory.py @@ -0,0 +1,300 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic inventory Bundle used to exercise the experimental runtime.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + +from hugegraph_llm.extraction_runtime.provider import ( + EffectiveRequestV1, + ProviderCapabilitiesV1, + ProviderDialectV1, + ProviderMessageV1, + ProviderNeutralRequestV1, + ProviderTransportV1, +) +from hugegraph_llm.extraction_runtime.v1 import ( + DomainSemanticManifestV1, + GateDisposition, + GateOutcomeV1, + GraphSnapshotV1, + IdentityOutcomeV1, + JsonObject, + NormalizedChunkV1, + RepairOutcomeV1, + RepairRequestV1, + ReviewDisposition, + ReviewOutcomeV1, + SemanticResourceV1, + ValidationOutcomeV1, + canonical_json, +) +from hugegraph_llm.extraction_runtime.v1.json_value import freeze_json_object, thaw_json + +_EXTRACT_PROMPT = "Extract inventory items as SKU and non-negative integer count." +_REPAIR_PROMPT = "Repair the inventory graph using the supplied reason and current graph." +_GRAPH_SCHEMA: JsonObject = { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["sku", "count"], + "properties": {"sku": {"type": "string"}, "count": {"type": "integer", "minimum": 0}}, + }, + } + }, +} +_GRAPH_TOOL: JsonObject = { + "type": "function", + "function": {"name": "emit_inventory_graph", "parameters": _GRAPH_SCHEMA}, +} + + +@dataclass(frozen=True) +class InventoryPolicyV1: + minimum_count: int = 1 + blocked_skus: tuple[str, ...] = () + gate_disposition: GateDisposition = GateDisposition.PASS + + def __post_init__(self) -> None: + if self.minimum_count < 0: + raise ValueError("minimum_count must be non-negative") + + +class InventoryBundleV1: + """Small realistic Bundle with independent graph and review semantics.""" + + model = "inventory-replay" + + def __init__(self, provider: ProviderTransportV1, policy: InventoryPolicyV1 | None = None) -> None: + self.provider = provider + self.policy = policy or InventoryPolicyV1() + self.dialect = ProviderDialectV1() + self.capabilities = ProviderCapabilitiesV1( + structured_tools=True, + strict_schema=True, + parallel_tool_calls=True, + ) + + def semantic_manifest(self) -> DomainSemanticManifestV1: + return DomainSemanticManifestV1( + bundle_id="inventory-conformance", + bundle_version="1", + resources=( + SemanticResourceV1.from_text("extract-prompt", _EXTRACT_PROMPT), + SemanticResourceV1.from_text("repair-prompt", _REPAIR_PROMPT), + SemanticResourceV1.from_text( + "graph-schema", canonical_json(_GRAPH_SCHEMA), media_type="application/json" + ), + ), + semantics={ + "identity": "sku-exact/v1", + "minimum_count": self.policy.minimum_count, + "blocked_skus": list(self.policy.blocked_skus), + "gate_disposition": self.policy.gate_disposition.value, + "materializer": "inventory-provider-output/v1", + }, + ) + + def provider_execution(self) -> JsonObject: + return freeze_json_object( + { + "adapter_contract": self.dialect.contract, + "capabilities_contract": self.capabilities.contract, + "model": self.model, + "temperature": 0.0, + "max_output_tokens": 512, + "structured_output": "tool-and-schema", + "strict_schema": True, + "parallel_tool_calls": False, + "timeout_seconds": 30.0, + "retry_policy": {"max_attempts": 1, "backoff_seconds": 0.0}, + } + ) + + def plan_extract(self, chunk: NormalizedChunkV1) -> EffectiveRequestV1: + return self.dialect.plan( + ProviderNeutralRequestV1( + stage="extract", + model=self.model, + messages=( + ProviderMessageV1(role="system", content=_EXTRACT_PROMPT), + ProviderMessageV1(role="user", content=chunk.text), + ), + max_output_tokens=512, + temperature=0.0, + tools=(_GRAPH_TOOL,), + response_schema=_GRAPH_SCHEMA, + strict_schema=True, + parallel_tool_calls=False, + ), + self.capabilities, + ) + + def plan_repair( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + request: RepairRequestV1, + ) -> EffectiveRequestV1: + repair_input = canonical_json( + { + "reason": request.reason.value, + "expected_graph_digest": request.expected_graph_digest, + "context": request.context, + "graph": graph.graph, + "chunk": chunk.text, + } + ) + return self.dialect.plan( + ProviderNeutralRequestV1( + stage="repair", + model=self.model, + messages=( + ProviderMessageV1(role="system", content=_REPAIR_PROMPT), + ProviderMessageV1(role="user", content=repair_input), + ), + max_output_tokens=512, + temperature=0.0, + tools=(_GRAPH_TOOL,), + response_schema=_GRAPH_SCHEMA, + strict_schema=True, + parallel_tool_calls=False, + ), + self.capabilities, + ) + + def extract(self, chunk: NormalizedChunkV1) -> JsonObject: + response = self.provider.execute(self.plan_extract(chunk)) + return self._materialize_graph(response.output) + + def validate_schema(self, graph: GraphSnapshotV1, chunk: NormalizedChunkV1) -> ValidationOutcomeV1: + del chunk + plain = thaw_json(graph.graph) + valid = isinstance(plain, dict) and self._valid_items(plain.get("items")) + diagnostics = () if valid else ({"code": "inventory_schema_invalid"},) + return ValidationOutcomeV1(valid=valid, diagnostics=diagnostics) + + def identify(self, graph: GraphSnapshotV1, chunk: NormalizedChunkV1) -> IdentityOutcomeV1: + del chunk + items = self._items(graph.graph) + skus = [self._sku(item) for item in items] + valid = len(set(skus)) == len(skus) + diagnostics = () if valid else ({"code": "duplicate_inventory_sku"},) + return IdentityOutcomeV1(valid=valid, identity={"skus": skus}, diagnostics=diagnostics) + + def review( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + validation: ValidationOutcomeV1, + identity: IdentityOutcomeV1, + ) -> ReviewOutcomeV1: + del chunk, validation, identity + items = self._items(graph.graph) + blocked = sorted(self._sku(item) for item in items if self._sku(item) in self.policy.blocked_skus) + low = sorted(self._sku(item) for item in items if self._count(item) < self.policy.minimum_count) + if blocked: + disposition = ReviewDisposition.BLOCK + findings = ({"code": "blocked_sku", "skus": blocked},) + elif low: + disposition = ReviewDisposition.FIX + findings = ({"code": "count_below_minimum", "skus": low},) + else: + disposition = ReviewDisposition.PASS + findings = () + return ReviewOutcomeV1( + disposition=disposition, + expected_graph_digest=graph.graph_digest, + findings=findings, + ) + + def repair( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + request: RepairRequestV1, + ) -> RepairOutcomeV1: + response = self.provider.execute(self.plan_repair(graph, chunk, request)) + patch = response.output.get("patch") + return RepairOutcomeV1( + base_graph_digest=graph.graph_digest, + candidate_graph=self._materialize_graph(response.output), + patch=patch if isinstance(patch, Mapping) else None, + ) + + def final_gate( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + validation: ValidationOutcomeV1, + identity: IdentityOutcomeV1, + review: ReviewOutcomeV1, + ) -> GateOutcomeV1: + del chunk, validation, identity, review + return GateOutcomeV1( + disposition=self.policy.gate_disposition, + expected_graph_digest=graph.graph_digest, + report={"item_count": len(self._items(graph.graph))}, + ) + + @staticmethod + def _materialize_graph(output: JsonObject) -> JsonObject: + graph = output.get("graph") + if not isinstance(graph, Mapping): + raise TypeError("provider output must contain a graph object") + return freeze_json_object(graph) + + @staticmethod + def _valid_items(value: object) -> bool: + if not isinstance(value, list): + return False + return all( + isinstance(item, dict) + and isinstance(item.get("sku"), str) + and bool(item["sku"]) + and isinstance(item.get("count"), int) + and not isinstance(item.get("count"), bool) + and item["count"] >= 0 + for item in value + ) + + @staticmethod + def _items(graph: JsonObject) -> list[dict[str, object]]: + plain = thaw_json(graph) + if not isinstance(plain, dict) or not InventoryBundleV1._valid_items(plain.get("items")): + return [] + items = plain["items"] + if not isinstance(items, list): + return [] + return [item for item in items if isinstance(item, dict)] + + @staticmethod + def _sku(item: dict[str, object]) -> str: + value = item["sku"] + if not isinstance(value, str): + raise TypeError("inventory SKU must be a string") + return value + + @staticmethod + def _count(item: dict[str, object]) -> int: + value = item["count"] + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError("inventory count must be an integer") + return value diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/__init__.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/__init__.py new file mode 100644 index 000000000..6b800cda2 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/__init__.py @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Experimental provider-neutral seam; no production transport is wired.""" + +from hugegraph_llm.extraction_runtime.provider.contracts import ( + AdaptationAction, + AdaptationDecisionV1, + AdaptationRecordV1, + EffectiveRequestV1, + ProviderAdapterV1, + ProviderCapabilitiesV1, + ProviderMessageV1, + ProviderNeutralRequestV1, + ProviderResponseV1, + ProviderTransportV1, + RetryPolicyV1, + UnsupportedProviderParameterError, +) +from hugegraph_llm.extraction_runtime.provider.dialect import ProviderDialectV1 +from hugegraph_llm.extraction_runtime.provider.replay import ( + ReplayEntryV1, + ReplayExhaustedError, + ReplayMismatchError, + ReplayProvider, + ReplayProviderError, +) + +__all__ = [ + "AdaptationAction", + "AdaptationDecisionV1", + "AdaptationRecordV1", + "EffectiveRequestV1", + "ProviderAdapterV1", + "ProviderCapabilitiesV1", + "ProviderDialectV1", + "ProviderMessageV1", + "ProviderNeutralRequestV1", + "ProviderResponseV1", + "ProviderTransportV1", + "ReplayEntryV1", + "ReplayExhaustedError", + "ReplayMismatchError", + "ReplayProvider", + "ReplayProviderError", + "RetryPolicyV1", + "UnsupportedProviderParameterError", +] diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/contracts.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/contracts.py new file mode 100644 index 000000000..a4b8d068b --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/contracts.py @@ -0,0 +1,240 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Credential-free provider-neutral execution contracts.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from enum import Enum +from typing import Literal, Protocol + +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject, digest_json, freeze_json_object + +_CREDENTIAL_PARAMETER_NAMES = { + "api_key", + "authorization", + "cookie", + "cookies", + "password", + "secret", + "token", +} +_RESERVED_PARAMETER_NAMES = { + "max_output_tokens", + "messages", + "model", + "parallel_tool_calls", + "reasoning_effort", + "response_schema", + "retry_policy", + "strict_schema", + "thinking", + "timeout_seconds", + "tools", +} + + +class UnsupportedProviderParameterError(ValueError): + """Raised when removing a parameter would change required semantics.""" + + +class AdaptationAction(str, Enum): + KEPT = "kept" + DROPPED = "dropped" + DOWNGRADED = "downgraded" + + +@dataclass(frozen=True) +class ProviderMessageV1: + role: Literal["system", "user", "assistant", "tool"] + content: str + name: str | None = None + + def __post_init__(self) -> None: + if not self.content: + raise ValueError("provider message content must not be empty") + + +@dataclass(frozen=True) +class RetryPolicyV1: + max_attempts: int = 1 + backoff_seconds: float = 0.0 + + def __post_init__(self) -> None: + if self.max_attempts < 1: + raise ValueError("max_attempts must be at least one") + if not math.isfinite(self.backoff_seconds) or self.backoff_seconds < 0: + raise ValueError("backoff_seconds must be finite and non-negative") + + +@dataclass(frozen=True) +class ProviderNeutralRequestV1: + stage: str + model: str + messages: tuple[ProviderMessageV1, ...] + max_output_tokens: int = 1024 + temperature: float = 0.0 + reasoning_effort: str | None = None + thinking: JsonObject | None = None + tools: tuple[JsonObject, ...] = () + response_schema: JsonObject | None = None + strict_schema: bool = False + parallel_tool_calls: bool | None = None + optional_parameters: JsonObject = field(default_factory=dict) + timeout_seconds: float = 30.0 + retry_policy: RetryPolicyV1 = field(default_factory=RetryPolicyV1) + contract: Literal["provider-neutral-request/v1"] = "provider-neutral-request/v1" + + def __post_init__(self) -> None: + if not self.stage: + raise ValueError("provider request stage must not be empty") + if not self.model: + raise ValueError("provider request model must not be empty") + if not self.messages: + raise ValueError("provider request must contain at least one message") + if self.max_output_tokens < 1: + raise ValueError("max_output_tokens must be positive") + if not math.isfinite(self.temperature) or self.temperature < 0: + raise ValueError("temperature must be finite and non-negative") + if not math.isfinite(self.timeout_seconds) or self.timeout_seconds <= 0: + raise ValueError("timeout_seconds must be finite and positive") + if self.strict_schema and not (self.tools or self.response_schema): + raise ValueError("strict_schema requires tools or a response schema") + if self.parallel_tool_calls is not None and not self.tools: + raise ValueError("parallel_tool_calls requires tools") + frozen_optional = freeze_json_object(self.optional_parameters) + for name in frozen_optional: + normalized = name.lower().replace("-", "_") + if normalized in _CREDENTIAL_PARAMETER_NAMES or normalized.endswith("_token"): + raise ValueError(f"credential parameter {name!r} is forbidden") + if normalized in _RESERVED_PARAMETER_NAMES: + raise ValueError(f"optional parameter {name!r} collides with a typed field") + object.__setattr__(self, "optional_parameters", frozen_optional) + object.__setattr__(self, "tools", tuple(freeze_json_object(tool) for tool in self.tools)) + if self.thinking is not None: + object.__setattr__(self, "thinking", freeze_json_object(self.thinking)) + if self.response_schema is not None: + object.__setattr__(self, "response_schema", freeze_json_object(self.response_schema)) + + def as_evidence_payload(self) -> JsonObject: + return freeze_json_object( + { + "contract": self.contract, + "stage": self.stage, + "model": self.model, + "messages": [ + {"role": message.role, "content": message.content, "name": message.name} + for message in self.messages + ], + "max_output_tokens": self.max_output_tokens, + "temperature": self.temperature, + "reasoning_effort": self.reasoning_effort, + "thinking": self.thinking, + "tools": list(self.tools), + "response_schema": self.response_schema, + "strict_schema": self.strict_schema, + "parallel_tool_calls": self.parallel_tool_calls, + "optional_parameters": self.optional_parameters, + "timeout_seconds": self.timeout_seconds, + "retry_policy": { + "max_attempts": self.retry_policy.max_attempts, + "backoff_seconds": self.retry_policy.backoff_seconds, + }, + } + ) + + +@dataclass(frozen=True) +class ProviderCapabilitiesV1: + reasoning_effort: bool = False + thinking: bool = False + structured_tools: bool = False + strict_schema: bool = False + parallel_tool_calls: bool = False + optional_parameters: tuple[str, ...] = () + contract: Literal["provider-capabilities/v1"] = "provider-capabilities/v1" + + def __post_init__(self) -> None: + if len(set(self.optional_parameters)) != len(self.optional_parameters): + raise ValueError("provider optional capability names must be unique") + + +@dataclass(frozen=True) +class AdaptationDecisionV1: + parameter: str + action: AdaptationAction + reason_code: str + requested_category: str + effective_category: str + + +@dataclass(frozen=True) +class AdaptationRecordV1: + adapter_contract: str + requested_digest: str + effective_digest: str + decisions: tuple[AdaptationDecisionV1, ...] + + +@dataclass(frozen=True) +class EffectiveRequestV1: + payload: JsonObject + adaptation: AdaptationRecordV1 + contract: Literal["provider-effective-request/v1"] = "provider-effective-request/v1" + + def __post_init__(self) -> None: + frozen = freeze_json_object(self.payload) + if digest_json(frozen) != self.adaptation.effective_digest: + raise ValueError("adaptation effective_digest does not bind the effective payload") + object.__setattr__(self, "payload", frozen) + + +@dataclass(frozen=True) +class ProviderResponseV1: + output: JsonObject + model: str + model_revision: str | None = None + usage: JsonObject = field(default_factory=dict) + contract: Literal["provider-response/v1"] = "provider-response/v1" + + def __post_init__(self) -> None: + if not self.model: + raise ValueError("provider response model must not be empty") + object.__setattr__(self, "output", freeze_json_object(self.output)) + object.__setattr__(self, "usage", freeze_json_object(self.usage)) + + @property + def response_digest(self) -> str: + return digest_json( + { + "contract": self.contract, + "output": self.output, + "model": self.model, + "model_revision": self.model_revision, + "usage": self.usage, + } + ) + + +class ProviderAdapterV1(Protocol): + def plan( + self, + request: ProviderNeutralRequestV1, + capabilities: ProviderCapabilitiesV1, + ) -> EffectiveRequestV1: ... + + +class ProviderTransportV1(Protocol): + def execute(self, request: EffectiveRequestV1) -> ProviderResponseV1: ... diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/dialect.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/dialect.py new file mode 100644 index 000000000..10e284f78 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/dialect.py @@ -0,0 +1,171 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Provider capability planning without transport or credentials.""" + +from __future__ import annotations + +from hugegraph_llm.extraction_runtime.provider.contracts import ( + AdaptationAction, + AdaptationDecisionV1, + AdaptationRecordV1, + EffectiveRequestV1, + ProviderCapabilitiesV1, + ProviderNeutralRequestV1, + UnsupportedProviderParameterError, +) +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject, digest_json, freeze_json_object + + +class ProviderDialectV1: + """Plan a credential-free effective request from explicit capabilities.""" + + contract = "provider-dialect/v1" + + def plan( + self, + request: ProviderNeutralRequestV1, + capabilities: ProviderCapabilitiesV1, + ) -> EffectiveRequestV1: + payload: dict[str, object] = { + "model": request.model, + "messages": [ + {"role": message.role, "content": message.content, "name": message.name} for message in request.messages + ], + "max_output_tokens": request.max_output_tokens, + "temperature": request.temperature, + "timeout_seconds": request.timeout_seconds, + "retry_policy": { + "max_attempts": request.retry_policy.max_attempts, + "backoff_seconds": request.retry_policy.backoff_seconds, + }, + } + decisions: list[AdaptationDecisionV1] = [] + + self._optional( + payload, + decisions, + parameter="reasoning_effort", + value=request.reasoning_effort, + supported=capabilities.reasoning_effort, + ) + self._optional( + payload, + decisions, + parameter="thinking", + value=request.thinking, + supported=capabilities.thinking, + ) + + if request.tools or request.response_schema is not None: + if not capabilities.structured_tools: + raise UnsupportedProviderParameterError("structured_tools are required by this request") + if request.tools: + payload["tools"] = list(request.tools) + if request.response_schema is not None: + payload["response_schema"] = request.response_schema + decisions.append(self._kept("structured_tools", "object")) + + if request.strict_schema: + if capabilities.strict_schema: + payload["strict_schema"] = True + decisions.append(self._kept("strict_schema", "boolean")) + else: + payload["strict_schema"] = False + decisions.append( + AdaptationDecisionV1( + parameter="strict_schema", + action=AdaptationAction.DOWNGRADED, + reason_code="unsupported_strict_schema", + requested_category="true", + effective_category="false", + ) + ) + + self._optional( + payload, + decisions, + parameter="parallel_tool_calls", + value=request.parallel_tool_calls, + supported=capabilities.parallel_tool_calls, + ) + + supported_optional = set(capabilities.optional_parameters) + for name in sorted(request.optional_parameters): + self._optional( + payload, + decisions, + parameter=name, + value=request.optional_parameters[name], + supported=name in supported_optional, + ) + + effective_payload: JsonObject = freeze_json_object(payload) + effective_digest = digest_json(effective_payload) + record = AdaptationRecordV1( + adapter_contract=self.contract, + requested_digest=digest_json(request.as_evidence_payload()), + effective_digest=effective_digest, + decisions=tuple(decisions), + ) + return EffectiveRequestV1(payload=effective_payload, adaptation=record) + + @staticmethod + def _optional( + payload: dict[str, object], + decisions: list[AdaptationDecisionV1], + *, + parameter: str, + value: object, + supported: bool, + ) -> None: + if value is None: + return + category = ProviderDialectV1._category(value) + if supported: + payload[parameter] = value + decisions.append(ProviderDialectV1._kept(parameter, category)) + return + decisions.append( + AdaptationDecisionV1( + parameter=parameter, + action=AdaptationAction.DROPPED, + reason_code="unsupported_optional_parameter", + requested_category=category, + effective_category="absent", + ) + ) + + @staticmethod + def _kept(parameter: str, category: str) -> AdaptationDecisionV1: + return AdaptationDecisionV1( + parameter=parameter, + action=AdaptationAction.KEPT, + reason_code="provider_supports_parameter", + requested_category=category, + effective_category=category, + ) + + @staticmethod + def _category(value: object) -> str: + if isinstance(value, bool): + return "boolean" + if isinstance(value, str): + return "string" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, tuple | list): + return "array" + if isinstance(value, dict) or hasattr(value, "items"): + return "object" + return type(value).__name__ diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/replay.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/replay.py new file mode 100644 index 000000000..3b6c251d6 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/provider/replay.py @@ -0,0 +1,72 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Credential-free deterministic provider transcript replay.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hugegraph_llm.extraction_runtime.provider.contracts import EffectiveRequestV1, ProviderResponseV1 +from hugegraph_llm.extraction_runtime.v1.json_value import digest_json + + +class ReplayProviderError(RuntimeError): + """Base replay transcript error.""" + + +class ReplayMismatchError(ReplayProviderError): + """Raised when the next transcript request is not the effective request.""" + + +class ReplayExhaustedError(ReplayProviderError): + """Raised when execution exceeds the frozen transcript.""" + + +@dataclass(frozen=True) +class ReplayEntryV1: + requested_request_digest: str + effective_request_digest: str + response: ProviderResponseV1 + + +class ReplayProvider: + """Execute an ordered immutable transcript with exact request matching.""" + + def __init__(self, entries: tuple[ReplayEntryV1, ...]) -> None: + self._entries = entries + self._position = 0 + self._effective_requests: tuple[EffectiveRequestV1, ...] = () + + @property + def effective_requests(self) -> tuple[EffectiveRequestV1, ...]: + return self._effective_requests + + @property + def remaining(self) -> int: + return len(self._entries) - self._position + + def execute(self, request: EffectiveRequestV1) -> ProviderResponseV1: + if self._position >= len(self._entries): + raise ReplayExhaustedError("replay transcript is exhausted") + actual_digest = digest_json(request.payload) + if actual_digest != request.adaptation.effective_digest: + raise ReplayMismatchError("effective request payload no longer matches its adaptation record") + entry = self._entries[self._position] + if entry.requested_request_digest != request.adaptation.requested_digest: + raise ReplayMismatchError("provider-neutral request digest does not match the next replay entry") + if entry.effective_request_digest != actual_digest: + raise ReplayMismatchError("effective request digest does not match the next replay entry") + self._effective_requests += (request,) + self._position += 1 + return entry.response diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/resources/__init__.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/resources/__init__.py new file mode 100644 index 000000000..bbac50451 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/resources/__init__.py @@ -0,0 +1,14 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Packaged resources for the dormant extraction runtime.""" diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/resources/runtime-contract-v1.json b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/resources/runtime-contract-v1.json new file mode 100644 index 000000000..9bd8cff4d --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/resources/runtime-contract-v1.json @@ -0,0 +1,25 @@ +{ + "schema": "hugegraph-ai/extraction-runtime-resource", + "resource_version": 1, + "license": "Apache-2.0", + "runtime_contract": { + "contract": "extraction-runtime/v1", + "phase_order": [ + "extract", + "schema", + "identity", + "review_fix", + "final_gate" + ], + "terminal_contract": "extraction-terminal-body/v1", + "graph_state_contract": "immutable-current-graph/v1" + }, + "terminal_kinds": [ + "final", + "candidate", + "blocked", + "failed" + ], + "public_integration": "none", + "stability": "experimental" +} diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/__init__.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/__init__.py new file mode 100644 index 000000000..04bef035a --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/__init__.py @@ -0,0 +1,118 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Experimental versioned extraction contracts and chunk execution.""" + +from hugegraph_llm.extraction_runtime.v1.artifacts import build_terminal_artifact_body +from hugegraph_llm.extraction_runtime.v1.batch import ChunkRunResultV1, run_chunks_v1 +from hugegraph_llm.extraction_runtime.v1.contracts import ( + FailureDisposition, + FailureOutcomeV1, + GateDisposition, + GateOutcomeV1, + GraphSnapshotV1, + IdentityOutcomeV1, + NormalizedChunkV1, + RepairOutcomeV1, + RepairReason, + RepairRequestV1, + ReviewDisposition, + ReviewOutcomeV1, + TerminalArtifactBodyV1, + TerminalIntentV1, + TerminalKind, + ValidationOutcomeV1, +) +from hugegraph_llm.extraction_runtime.v1.diagnostics import DiagnosticSeverity, DiagnosticV1 +from hugegraph_llm.extraction_runtime.v1.engine import ( + ExtractionBundleV1, + ExtractionEngineV1, + ExtractionRunResultV1, + RunControlV1, +) +from hugegraph_llm.extraction_runtime.v1.errors import ( + ArtifactConstructionError, + BudgetExhaustedError, + ExtractionRuntimeError, + InvalidGraphError, + RepairStageError, + RuntimeInvariantError, + StaleGraphError, +) +from hugegraph_llm.extraction_runtime.v1.fingerprint import ( + FingerprintLayersV1, + compose_run_fingerprint, + compute_input_digest, +) +from hugegraph_llm.extraction_runtime.v1.graph_state import GraphStateV1 +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject, JsonValue, canonical_json, digest_json +from hugegraph_llm.extraction_runtime.v1.manifest import DomainSemanticManifestV1, SemanticResourceV1 +from hugegraph_llm.extraction_runtime.v1.review_loop import ReviewBudgetStateV1, ReviewBudgetV1 +from hugegraph_llm.extraction_runtime.v1.terminal import ( + TerminalEvidenceV1, + TerminalResolutionV1, + resolve_terminal, +) +from hugegraph_llm.extraction_runtime.v1.trace import TraceEventV1, TraceRecorderV1 + +__all__ = [ + "ArtifactConstructionError", + "BudgetExhaustedError", + "ChunkRunResultV1", + "DiagnosticSeverity", + "DiagnosticV1", + "DomainSemanticManifestV1", + "ExtractionBundleV1", + "ExtractionEngineV1", + "ExtractionRunResultV1", + "ExtractionRuntimeError", + "FailureDisposition", + "FailureOutcomeV1", + "FingerprintLayersV1", + "GateDisposition", + "GateOutcomeV1", + "GraphSnapshotV1", + "GraphStateV1", + "IdentityOutcomeV1", + "InvalidGraphError", + "JsonObject", + "JsonValue", + "NormalizedChunkV1", + "RepairOutcomeV1", + "RepairReason", + "RepairRequestV1", + "RepairStageError", + "ReviewBudgetStateV1", + "ReviewBudgetV1", + "ReviewDisposition", + "ReviewOutcomeV1", + "RunControlV1", + "RuntimeInvariantError", + "SemanticResourceV1", + "StaleGraphError", + "TerminalArtifactBodyV1", + "TerminalEvidenceV1", + "TerminalIntentV1", + "TerminalKind", + "TerminalResolutionV1", + "TraceEventV1", + "TraceRecorderV1", + "ValidationOutcomeV1", + "build_terminal_artifact_body", + "canonical_json", + "compose_run_fingerprint", + "compute_input_digest", + "digest_json", + "resolve_terminal", + "run_chunks_v1", +] diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/artifacts.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/artifacts.py new file mode 100644 index 000000000..6e37e5f0f --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/artifacts.py @@ -0,0 +1,57 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pure terminal artifact body construction with no storage I/O.""" + +from __future__ import annotations + +from hugegraph_llm.extraction_runtime.v1.contracts import ( + GraphSnapshotV1, + TerminalArtifactBodyV1, + TerminalIntentV1, +) +from hugegraph_llm.extraction_runtime.v1.errors import RuntimeInvariantError +from hugegraph_llm.extraction_runtime.v1.fingerprint import FingerprintLayersV1 +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject + + +def build_terminal_artifact_body( + *, + intent: TerminalIntentV1, + graph: GraphSnapshotV1 | None, + review: JsonObject | None, + final_gate: JsonObject | None, + trace_head: str | None, + fingerprints: FingerprintLayersV1, +) -> TerminalArtifactBodyV1: + if graph is None: + if intent.graph_revision is not None or intent.graph_digest is not None: + raise RuntimeInvariantError("terminal intent references a graph when no current graph exists") + else: + if intent.graph_revision != graph.revision: + raise RuntimeInvariantError("terminal intent graph revision does not match current graph") + if intent.graph_digest != graph.graph_digest: + raise RuntimeInvariantError("terminal intent graph digest does not match current graph") + return TerminalArtifactBodyV1( + intent=intent, + graph=graph.graph if graph else None, + review=review, + final_gate=final_gate, + trace_head=trace_head, + run_fingerprint=fingerprints.run_fingerprint, + runtime_contract_digest=fingerprints.runtime_contract_digest, + domain_semantic_digest=fingerprints.domain_semantic_digest, + provider_execution_digest=fingerprints.provider_execution_digest, + input_digest=fingerprints.input_digest, + host_plan_digest=fingerprints.host_plan_digest, + ) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/batch.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/batch.py new file mode 100644 index 000000000..f77b55fc3 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/batch.py @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Concurrent execution of independent chunks using the single-chunk engine.""" + +from collections.abc import Callable, Iterable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass + +from hugegraph_llm.extraction_runtime.v1.contracts import NormalizedChunkV1 +from hugegraph_llm.extraction_runtime.v1.engine import ( + ExtractionBundleV1, + ExtractionEngineV1, + ExtractionRunResultV1, + RunControlV1, +) + + +@dataclass(frozen=True) +class ChunkRunResultV1: + chunk: NormalizedChunkV1 + result: ExtractionRunResultV1 + + +def run_chunks_v1( + *, + chunks: Iterable[NormalizedChunkV1], + prepare: Callable[[NormalizedChunkV1], tuple[ExtractionBundleV1, RunControlV1]], + max_workers: int = 4, +) -> tuple[ChunkRunResultV1, ...]: + """Run a finite batch with at most ``max_workers`` chunks executing at once. + + ``prepare`` runs in worker threads and must create a separate Bundle and + stateful Provider for each chunk. It returns that Bundle and its control. + Results follow input order, regardless of completion order or chunk ordinal. + Engine failure terminals are collected normally; preparation and input + iteration errors propagate to the caller. The pool closes after running + tasks finish; preparation errors may cancel tasks that have not started. + The full batch is submitted and retained in memory. + """ + + def run_chunk(chunk: NormalizedChunkV1) -> ChunkRunResultV1: + bundle, control = prepare(chunk) + result = ExtractionEngineV1().run(bundle=bundle, chunk=chunk, control=control) + return ChunkRunResultV1(chunk=chunk, result=result) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + return tuple(executor.map(run_chunk, chunks)) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/contracts.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/contracts.py new file mode 100644 index 000000000..600bce20e --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/contracts.py @@ -0,0 +1,271 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Python 3.10-compatible typed control contracts for extraction runtime v1.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Literal + +from hugegraph_llm.extraction_runtime.v1.json_value import ( + JsonObject, + digest_json, + ensure_credential_free, + freeze_json_object, +) + + +def _freeze_objects(values: tuple[JsonObject, ...]) -> tuple[JsonObject, ...]: + frozen = tuple(freeze_json_object(value) for value in values) + for index, value in enumerate(frozen): + ensure_credential_free(value, path=f"$[{index}]") + return frozen + + +def _freeze_credential_free(value: JsonObject, *, path: str) -> JsonObject: + frozen = freeze_json_object(value) + ensure_credential_free(frozen, path=path) + return frozen + + +class TerminalKind(str, Enum): + FINAL = "final" + CANDIDATE = "candidate" + BLOCKED = "blocked" + FAILED = "failed" + + +class ReviewDisposition(str, Enum): + PASS = "pass" + FIX = "fix" + BLOCK = "block" + + +class GateDisposition(str, Enum): + PASS = "pass" + HOLD = "hold" + BLOCK = "block" + + +class FailureDisposition(str, Enum): + RETRY = "retry" + CANDIDATE = "candidate" + BLOCKED = "blocked" + FAILED = "failed" + + +class RepairReason(str, Enum): + SCHEMA = "schema" + IDENTITY = "identity" + REVIEW = "review" + + +@dataclass(frozen=True) +class NormalizedChunkV1: + document_id: str + chunk_id: str + ordinal: int + text: str + visible_metadata: JsonObject = field(default_factory=dict) + input_digest: str = "" + contract: Literal["normalized-chunk/v1"] = "normalized-chunk/v1" + + def __post_init__(self) -> None: + if not self.document_id: + raise ValueError("document_id must not be empty") + if not self.chunk_id: + raise ValueError("chunk_id must not be empty") + if self.ordinal < 0: + raise ValueError("ordinal must be non-negative") + if self.contract != "normalized-chunk/v1": + raise ValueError("unsupported normalized chunk contract") + object.__setattr__( + self, + "visible_metadata", + _freeze_credential_free(self.visible_metadata, path="$.visible_metadata"), + ) + + +@dataclass(frozen=True) +class GraphSnapshotV1: + revision: int + graph: JsonObject + graph_digest: str + + def __post_init__(self) -> None: + if self.revision < 0: + raise ValueError("graph revision must be non-negative") + frozen = _freeze_credential_free(self.graph, path="$.graph") + if digest_json(frozen) != self.graph_digest: + raise ValueError("graph_digest does not bind the graph payload") + object.__setattr__(self, "graph", frozen) + + +@dataclass(frozen=True) +class ValidationOutcomeV1: + valid: bool + diagnostics: tuple[JsonObject, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "diagnostics", _freeze_objects(self.diagnostics)) + + +@dataclass(frozen=True) +class IdentityOutcomeV1: + valid: bool + identity: JsonObject = field(default_factory=dict) + diagnostics: tuple[JsonObject, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "identity", _freeze_credential_free(self.identity, path="$.identity")) + object.__setattr__(self, "diagnostics", _freeze_objects(self.diagnostics)) + + +@dataclass(frozen=True) +class ReviewOutcomeV1: + disposition: ReviewDisposition + expected_graph_digest: str + findings: tuple[JsonObject, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "findings", _freeze_objects(self.findings)) + + +@dataclass(frozen=True) +class RepairOutcomeV1: + base_graph_digest: str + candidate_graph: JsonObject + patch: JsonObject | None = None + diagnostics: tuple[JsonObject, ...] = () + + def __post_init__(self) -> None: + object.__setattr__( + self, + "candidate_graph", + _freeze_credential_free(self.candidate_graph, path="$.candidate_graph"), + ) + if self.patch is not None: + object.__setattr__(self, "patch", _freeze_credential_free(self.patch, path="$.patch")) + object.__setattr__(self, "diagnostics", _freeze_objects(self.diagnostics)) + + +@dataclass(frozen=True) +class GateOutcomeV1: + disposition: GateDisposition + expected_graph_digest: str + report: JsonObject = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "report", _freeze_credential_free(self.report, path="$.report")) + + +@dataclass(frozen=True) +class FailureOutcomeV1: + disposition: FailureDisposition + reason_code: str + retryable: bool + diagnostics: tuple[JsonObject, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "diagnostics", _freeze_objects(self.diagnostics)) + + +@dataclass(frozen=True) +class RepairRequestV1: + reason: RepairReason + expected_graph_digest: str + context: JsonObject = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "context", _freeze_credential_free(self.context, path="$.context")) + + +@dataclass(frozen=True) +class TerminalIntentV1: + kind: TerminalKind + reason_code: str + graph_revision: int | None + graph_digest: str | None + retryable: bool + + def as_json_object(self) -> JsonObject: + return freeze_json_object( + { + "kind": self.kind.value, + "reason_code": self.reason_code, + "graph_revision": self.graph_revision, + "graph_digest": self.graph_digest, + "retryable": self.retryable, + } + ) + + +@dataclass(frozen=True) +class TerminalArtifactBodyV1: + intent: TerminalIntentV1 + graph: JsonObject | None + review: JsonObject | None + final_gate: JsonObject | None + trace_head: str | None + run_fingerprint: str + runtime_contract_digest: str + domain_semantic_digest: str + provider_execution_digest: str + input_digest: str + host_plan_digest: str | None + contract: Literal["extraction-terminal-body/v1"] = "extraction-terminal-body/v1" + + def __post_init__(self) -> None: + if self.contract != "extraction-terminal-body/v1": + raise ValueError("unsupported terminal artifact body contract") + for name in ("graph", "review", "final_gate"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, _freeze_credential_free(value, path=f"$.{name}")) + if self.graph is None: + if self.intent.graph_revision is not None or self.intent.graph_digest is not None: + raise ValueError("terminal intent references a graph missing from the artifact body") + else: + if self.intent.graph_revision is None: + raise ValueError("terminal artifact graph requires an intent graph revision") + if digest_json(self.graph) != self.intent.graph_digest: + raise ValueError("terminal intent graph digest does not bind the artifact graph") + required_digests = ( + self.run_fingerprint, + self.runtime_contract_digest, + self.domain_semantic_digest, + self.provider_execution_digest, + self.input_digest, + ) + if not all(required_digests): + raise ValueError("terminal artifact body requires complete fingerprint layers") + + def as_json_object(self) -> JsonObject: + return freeze_json_object( + { + "contract": self.contract, + "intent": self.intent.as_json_object(), + "graph": self.graph, + "review": self.review, + "final_gate": self.final_gate, + "trace_head": self.trace_head, + "runtime_contract_digest": self.runtime_contract_digest, + "domain_semantic_digest": self.domain_semantic_digest, + "provider_execution_digest": self.provider_execution_digest, + "input_digest": self.input_digest, + "host_plan_digest": self.host_plan_digest, + "run_fingerprint": self.run_fingerprint, + } + ) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/diagnostics.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/diagnostics.py new file mode 100644 index 000000000..e6825f184 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/diagnostics.py @@ -0,0 +1,44 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stable, credential-free runtime diagnostic envelopes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject, ensure_credential_free, freeze_json_object + + +class DiagnosticSeverity(str, Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +@dataclass(frozen=True) +class DiagnosticV1: + code: str + stage: str + severity: DiagnosticSeverity + details: JsonObject = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.code: + raise ValueError("diagnostic code must not be empty") + if not self.stage: + raise ValueError("diagnostic stage must not be empty") + details = freeze_json_object(self.details) + ensure_credential_free(details, path="$.details") + object.__setattr__(self, "details", details) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/engine.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/engine.py new file mode 100644 index 000000000..bd946f916 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/engine.py @@ -0,0 +1,485 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fixed domain-neutral single-chunk extraction engine.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from hugegraph_llm.extraction_runtime.v1.artifacts import build_terminal_artifact_body +from hugegraph_llm.extraction_runtime.v1.contracts import ( + GateOutcomeV1, + GraphSnapshotV1, + IdentityOutcomeV1, + NormalizedChunkV1, + RepairOutcomeV1, + RepairReason, + RepairRequestV1, + ReviewDisposition, + ReviewOutcomeV1, + TerminalArtifactBodyV1, + TerminalIntentV1, + TerminalKind, + ValidationOutcomeV1, +) +from hugegraph_llm.extraction_runtime.v1.diagnostics import DiagnosticSeverity, DiagnosticV1 +from hugegraph_llm.extraction_runtime.v1.errors import ArtifactConstructionError, RepairStageError +from hugegraph_llm.extraction_runtime.v1.fingerprint import FingerprintLayersV1, compose_run_fingerprint +from hugegraph_llm.extraction_runtime.v1.graph_state import GraphStateV1 +from hugegraph_llm.extraction_runtime.v1.json_value import ( + JsonObject, + digest_json, + ensure_stable_provenance, + freeze_json_object, +) +from hugegraph_llm.extraction_runtime.v1.manifest import DomainSemanticManifestV1 +from hugegraph_llm.extraction_runtime.v1.review_loop import ReviewBudgetStateV1, ReviewBudgetV1 +from hugegraph_llm.extraction_runtime.v1.terminal import TerminalEvidenceV1, TerminalResolutionV1, resolve_terminal +from hugegraph_llm.extraction_runtime.v1.trace import TraceRecorderV1 + + +class ExtractionBundleV1(Protocol): + def semantic_manifest(self) -> DomainSemanticManifestV1: ... + + def extract(self, chunk: NormalizedChunkV1) -> JsonObject: ... + + def validate_schema(self, graph: GraphSnapshotV1, chunk: NormalizedChunkV1) -> ValidationOutcomeV1: ... + + def identify(self, graph: GraphSnapshotV1, chunk: NormalizedChunkV1) -> IdentityOutcomeV1: ... + + def review( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + validation: ValidationOutcomeV1, + identity: IdentityOutcomeV1, + ) -> ReviewOutcomeV1: ... + + def repair( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + request: RepairRequestV1, + ) -> RepairOutcomeV1: ... + + def final_gate( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + validation: ValidationOutcomeV1, + identity: IdentityOutcomeV1, + review: ReviewOutcomeV1, + ) -> GateOutcomeV1: ... + + +@dataclass(frozen=True) +class RunControlV1: + budget: ReviewBudgetV1 + provider_execution: JsonObject + host_plan: JsonObject | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "provider_execution", freeze_json_object(self.provider_execution)) + ensure_stable_provenance(self.provider_execution, path="$.provider_execution") + if self.host_plan is not None: + object.__setattr__(self, "host_plan", freeze_json_object(self.host_plan)) + ensure_stable_provenance(self.host_plan, path="$.host_plan") + + +@dataclass(frozen=True) +class ExtractionRunResultV1: + intent: TerminalIntentV1 + artifact: TerminalArtifactBodyV1 + current_graph: GraphSnapshotV1 | None + budget: ReviewBudgetStateV1 + fingerprints: FingerprintLayersV1 + trace: TraceRecorderV1 + diagnostics: tuple[DiagnosticV1, ...] + + +class ExtractionEngineV1: + """Execute the v1 fixed lifecycle for one normalized chunk.""" + + def run( + self, + *, + bundle: ExtractionBundleV1, + chunk: NormalizedChunkV1, + control: RunControlV1, + ) -> ExtractionRunResultV1: + graph_state = GraphStateV1() + budget = ReviewBudgetStateV1(control.budget) + trace = TraceRecorderV1() + diagnostics: tuple[DiagnosticV1, ...] = () + + try: + fingerprints = compose_run_fingerprint( + manifest=bundle.semantic_manifest(), + provider_execution=control.provider_execution, + chunk=chunk, + host_plan=control.host_plan, + ) + except Exception as exc: # noqa: BLE001 - the runtime maps boundary failures to failed + return self._failed_without_fingerprints(exc, budget) + + try: + current = graph_state.promote_initial(bundle.extract(chunk)) + trace = trace.append("extract", "promoted", current, {}) + except Exception as exc: # noqa: BLE001 - the runtime maps boundary failures to failed + return self._failed( + code="extract_failed", + stage="extract", + exc=exc, + graph=graph_state.current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + ) + + last_review: ReviewOutcomeV1 | None = None + while True: + current = graph_state.current + if current is None: + return self._failed( + code="runtime_invariant", + stage="schema", + exc=RuntimeError("current graph disappeared"), + graph=None, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + ) + active_stage = "schema" + try: + validation = bundle.validate_schema(current, chunk) + trace = trace.append("schema", "pass" if validation.valid else "invalid", current, {}) + if not validation.valid: + if not budget.can_fix: + return self._terminal( + resolution=resolve_terminal(TerminalEvidenceV1(safe_graph=True, schema_valid=False)), + graph=current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + review=last_review, + gate=None, + ) + active_stage = "repair" + graph_state, budget, trace = self._repair( + bundle=bundle, + chunk=chunk, + graph_state=graph_state, + budget=budget, + trace=trace, + reason=RepairReason.SCHEMA, + ) + continue + + active_stage = "identity" + identity = bundle.identify(current, chunk) + trace = trace.append("identity", "pass" if identity.valid else "invalid", current, {}) + if not identity.valid: + if not budget.can_fix: + return self._terminal( + resolution=resolve_terminal( + TerminalEvidenceV1(safe_graph=True, schema_valid=True, identity_valid=False) + ), + graph=current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + review=last_review, + gate=None, + ) + active_stage = "repair" + graph_state, budget, trace = self._repair( + bundle=bundle, + chunk=chunk, + graph_state=graph_state, + budget=budget, + trace=trace, + reason=RepairReason.IDENTITY, + ) + continue + + if not budget.can_review: + return self._terminal( + resolution=resolve_terminal( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + quality_budget_exhausted=True, + ) + ), + graph=current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + review=last_review, + gate=None, + ) + budget = budget.consume_review() + active_stage = "review" + last_review = bundle.review(current, chunk, validation, identity) + self._require_current_digest(last_review.expected_graph_digest, current, "review") + trace = trace.append("review", last_review.disposition.value, current, {"attempt": budget.reviews_used}) + if last_review.disposition is ReviewDisposition.BLOCK: + return self._terminal( + resolution=resolve_terminal( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.BLOCK, + ) + ), + graph=current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + review=last_review, + gate=None, + ) + if last_review.disposition is ReviewDisposition.FIX: + if not budget.can_fix: + return self._terminal( + resolution=resolve_terminal( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.FIX, + quality_budget_exhausted=True, + ) + ), + graph=current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + review=last_review, + gate=None, + ) + active_stage = "repair" + graph_state, budget, trace = self._repair( + bundle=bundle, + chunk=chunk, + graph_state=graph_state, + budget=budget, + trace=trace, + reason=RepairReason.REVIEW, + review=last_review, + ) + continue + + active_stage = "final_gate" + gate = bundle.final_gate(current, chunk, validation, identity, last_review) + self._require_current_digest(gate.expected_graph_digest, current, "final_gate") + trace = trace.append("final_gate", gate.disposition.value, current, {}) + return self._terminal( + resolution=resolve_terminal( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.PASS, + gate=gate.disposition, + ) + ), + graph=current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + review=last_review, + gate=gate, + ) + except Exception as exc: # noqa: BLE001 - Bundle hooks are a typed failure boundary + if isinstance(exc, RepairStageError): + code, stage = "repair_failed", "repair" + elif isinstance(exc, ArtifactConstructionError): + code, stage = "artifact_construction_failed", "artifact" + else: + code, stage = "stage_failed", active_stage + return self._failed( + code=code, + stage=stage, + exc=exc, + graph=graph_state.current, + budget=budget, + fingerprints=fingerprints, + trace=trace, + diagnostics=diagnostics, + ) + + @staticmethod + def _repair( + *, + bundle: ExtractionBundleV1, + chunk: NormalizedChunkV1, + graph_state: GraphStateV1, + budget: ReviewBudgetStateV1, + trace: TraceRecorderV1, + reason: RepairReason, + review: ReviewOutcomeV1 | None = None, + ) -> tuple[GraphStateV1, ReviewBudgetStateV1, TraceRecorderV1]: + current = graph_state.current + if current is None: + raise RuntimeError("repair requires a current graph") + context: JsonObject = { + "review_disposition": review.disposition.value if review else None, + "review_findings": list(review.findings) if review else [], + } + try: + outcome = bundle.repair( + current, + chunk, + RepairRequestV1(reason=reason, expected_graph_digest=current.graph_digest, context=context), + ) + repaired = graph_state.promote_repair( + outcome.candidate_graph, + expected_base_digest=outcome.base_graph_digest, + ) + except Exception as exc: + raise RepairStageError("repair candidate was not promoted") from exc + budget = budget.consume_fix() + trace = trace.append("repair", "promoted", repaired, {"reason": reason.value, "fix": budget.fixes_used}) + return graph_state, budget, trace + + @staticmethod + def _require_current_digest(expected: str, current: GraphSnapshotV1, stage: str) -> None: + if expected != current.graph_digest: + raise RuntimeError(f"{stage} outcome targets a stale graph digest") + + @staticmethod + def _outcome_payload(outcome: ReviewOutcomeV1 | GateOutcomeV1 | None) -> JsonObject | None: + if outcome is None: + return None + if isinstance(outcome, ReviewOutcomeV1): + return { + "disposition": outcome.disposition.value, + "expected_graph_digest": outcome.expected_graph_digest, + "findings": list(outcome.findings), + } + return { + "disposition": outcome.disposition.value, + "expected_graph_digest": outcome.expected_graph_digest, + "report": outcome.report, + } + + def _terminal( + self, + *, + resolution: TerminalResolutionV1, + graph: GraphSnapshotV1, + budget: ReviewBudgetStateV1, + fingerprints: FingerprintLayersV1, + trace: TraceRecorderV1, + diagnostics: tuple[DiagnosticV1, ...], + review: ReviewOutcomeV1 | None, + gate: GateOutcomeV1 | None, + ) -> ExtractionRunResultV1: + kind = resolution.kind + reason_code = resolution.reason_code + retryable = resolution.retryable + trace = trace.append("terminal", kind.value, graph, {"reason_code": reason_code}) + intent = TerminalIntentV1( + kind=kind, + reason_code=reason_code, + graph_revision=graph.revision, + graph_digest=graph.graph_digest, + retryable=retryable, + ) + try: + artifact = build_terminal_artifact_body( + intent=intent, + graph=graph, + review=self._outcome_payload(review), + final_gate=self._outcome_payload(gate), + trace_head=trace.trace_head, + fingerprints=fingerprints, + ) + except Exception as exc: + raise ArtifactConstructionError("terminal artifact body construction failed") from exc + return ExtractionRunResultV1(intent, artifact, graph, budget, fingerprints, trace, diagnostics) + + def _failed( + self, + *, + code: str, + stage: str, + exc: Exception, + graph: GraphSnapshotV1 | None, + budget: ReviewBudgetStateV1, + fingerprints: FingerprintLayersV1, + trace: TraceRecorderV1, + diagnostics: tuple[DiagnosticV1, ...], + ) -> ExtractionRunResultV1: + diagnostic = DiagnosticV1( + code=code, + stage=stage, + severity=DiagnosticSeverity.ERROR, + details={"exception_type": type(exc.__cause__ or exc).__name__}, + ) + diagnostics += (diagnostic,) + trace = trace.append("terminal", TerminalKind.FAILED.value, graph, {"reason_code": code}) + intent = TerminalIntentV1( + kind=TerminalKind.FAILED, + reason_code=code, + graph_revision=graph.revision if graph else None, + graph_digest=graph.graph_digest if graph else None, + retryable=False, + ) + artifact = build_terminal_artifact_body( + intent=intent, + graph=graph, + review=None, + final_gate=None, + trace_head=trace.trace_head, + fingerprints=fingerprints, + ) + return ExtractionRunResultV1(intent, artifact, graph, budget, fingerprints, trace, diagnostics) + + def _failed_without_fingerprints( + self, + exc: Exception, + budget: ReviewBudgetStateV1, + ) -> ExtractionRunResultV1: + failure_digest = digest_json({"contract": "untrusted-fingerprint-failure/v1"}) + fallback = FingerprintLayersV1( + failure_digest, + failure_digest, + failure_digest, + failure_digest, + None, + failure_digest, + ) + return self._failed( + code="fingerprint_failed", + stage="fingerprint", + exc=exc, + graph=None, + budget=budget, + fingerprints=fallback, + trace=TraceRecorderV1(), + diagnostics=(), + ) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/errors.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/errors.py new file mode 100644 index 000000000..98a0f62c3 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/errors.py @@ -0,0 +1,42 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Runtime control-plane errors.""" + + +class ExtractionRuntimeError(Exception): + """Base error for the experimental runtime.""" + + +class RuntimeInvariantError(ExtractionRuntimeError): + """Raised when runtime-owned state violates an invariant.""" + + +class InvalidGraphError(ExtractionRuntimeError, ValueError): + """Raised before a non-canonical graph can become authoritative.""" + + +class StaleGraphError(ExtractionRuntimeError): + """Raised when a repair does not target the current graph digest.""" + + +class BudgetExhaustedError(ExtractionRuntimeError): + """Raised when a deterministic business budget cannot be consumed.""" + + +class RepairStageError(ExtractionRuntimeError): + """Raised when a repair candidate cannot be produced or promoted.""" + + +class ArtifactConstructionError(ExtractionRuntimeError): + """Raised when a semantic terminal cannot be sealed into a data body.""" diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/fingerprint.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/fingerprint.py new file mode 100644 index 000000000..c5d1b45fd --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/fingerprint.py @@ -0,0 +1,88 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Layered, explainable extraction run fingerprints.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hugegraph_llm.extraction_runtime.v1.contracts import NormalizedChunkV1 +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject, digest_json, ensure_stable_provenance +from hugegraph_llm.extraction_runtime.v1.manifest import DomainSemanticManifestV1 + +RUNTIME_CONTRACT = { + "contract": "extraction-runtime/v1", + "phase_order": ["extract", "schema", "identity", "review_fix", "final_gate"], + "terminal_contract": "extraction-terminal-body/v1", + "graph_state_contract": "immutable-current-graph/v1", +} + + +@dataclass(frozen=True) +class FingerprintLayersV1: + runtime_contract_digest: str + domain_semantic_digest: str + provider_execution_digest: str + input_digest: str + host_plan_digest: str | None + run_fingerprint: str + + +def compute_input_digest(chunk: NormalizedChunkV1) -> str: + return digest_json( + { + "contract": chunk.contract, + "document_id": chunk.document_id, + "chunk_id": chunk.chunk_id, + "ordinal": chunk.ordinal, + "text": chunk.text, + "visible_metadata": chunk.visible_metadata, + } + ) + + +def compose_run_fingerprint( + *, + manifest: DomainSemanticManifestV1, + provider_execution: JsonObject, + chunk: NormalizedChunkV1, + host_plan: JsonObject | None = None, +) -> FingerprintLayersV1: + ensure_stable_provenance(provider_execution, path="$.provider_execution") + if host_plan is not None: + ensure_stable_provenance(host_plan, path="$.host_plan") + input_digest = compute_input_digest(chunk) + if chunk.input_digest and chunk.input_digest != input_digest: + raise ValueError("normalized chunk input_digest does not bind its visible content") + runtime_contract_digest = digest_json(RUNTIME_CONTRACT) + domain_semantic_digest = manifest.domain_semantic_digest + provider_execution_digest = digest_json(provider_execution) + host_plan_digest = digest_json(host_plan) if host_plan is not None else None + run_fingerprint = digest_json( + { + "runtime_contract_digest": runtime_contract_digest, + "domain_semantic_digest": domain_semantic_digest, + "provider_execution_digest": provider_execution_digest, + "input_digest": input_digest, + "host_plan_digest": host_plan_digest, + } + ) + return FingerprintLayersV1( + runtime_contract_digest=runtime_contract_digest, + domain_semantic_digest=domain_semantic_digest, + provider_execution_digest=provider_execution_digest, + input_digest=input_digest, + host_plan_digest=host_plan_digest, + run_fingerprint=run_fingerprint, + ) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/graph_state.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/graph_state.py new file mode 100644 index 000000000..4e14a9498 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/graph_state.py @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Single-authority immutable graph revision state.""" + +from __future__ import annotations + +from hugegraph_llm.extraction_runtime.v1.contracts import GraphSnapshotV1 +from hugegraph_llm.extraction_runtime.v1.errors import RuntimeInvariantError, StaleGraphError +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject, digest_json, freeze_json_object + + +class GraphStateV1: + """Own exactly one authoritative immutable graph snapshot.""" + + def __init__(self) -> None: + self._current: GraphSnapshotV1 | None = None + + @property + def current(self) -> GraphSnapshotV1 | None: + return self._current + + def promote_initial(self, candidate_graph: JsonObject) -> GraphSnapshotV1: + if self._current is not None: + raise RuntimeInvariantError("initial graph has already been promoted") + snapshot = self._build_snapshot(candidate_graph, revision=0) + self._current = snapshot + return snapshot + + def promote_repair(self, candidate_graph: JsonObject, *, expected_base_digest: str) -> GraphSnapshotV1: + current = self._current + if current is None: + raise RuntimeInvariantError("cannot promote a repair before the initial graph") + if expected_base_digest != current.graph_digest: + raise StaleGraphError(f"repair targets {expected_base_digest!r}, current graph is {current.graph_digest!r}") + snapshot = self._build_snapshot(candidate_graph, revision=current.revision + 1) + self._current = snapshot + return snapshot + + @staticmethod + def _build_snapshot(candidate_graph: JsonObject, *, revision: int) -> GraphSnapshotV1: + frozen = freeze_json_object(candidate_graph) + return GraphSnapshotV1(revision=revision, graph=frozen, graph_digest=digest_json(frozen)) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/json_value.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/json_value.py new file mode 100644 index 000000000..d6e98e4ac --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/json_value.py @@ -0,0 +1,165 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Canonical JSON helpers shared by runtime contracts and graph state.""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import TypeAlias, cast + +from hugegraph_llm.extraction_runtime.v1.errors import InvalidGraphError + +JsonScalar: TypeAlias = None | bool | int | float | str +JsonValue: TypeAlias = JsonScalar | Mapping[str, "JsonValue"] | Sequence["JsonValue"] +JsonObject: TypeAlias = Mapping[str, JsonValue] + +_SENSITIVE_PROVENANCE_KEYS = { + "access_token", + "api_key", + "authorization", + "cookie", + "cookies", + "password", + "refresh_token", + "secret", + "token", +} +_VOLATILE_PROVENANCE_KEYS = { + "duration", + "ended_at", + "lease_owner", + "request_id", + "run_id", + "started_at", + "temporary_path", + "timestamp", + "trace_id", + "worker_id", +} +_SENSITIVE_PROVENANCE_KEYS_COMPACT = {key.replace("_", "") for key in _SENSITIVE_PROVENANCE_KEYS} +_VOLATILE_PROVENANCE_KEYS_COMPACT = {key.replace("_", "") for key in _VOLATILE_PROVENANCE_KEYS} + + +def canonical_json(value: object) -> str: + """Return a deterministic JSON representation after strict validation.""" + plain = _copy_json(value, path="$", require_object=False) + return json.dumps(plain, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")) + + +def digest_json(value: object) -> str: + """Return a version-explicit SHA-256 digest for canonical JSON.""" + encoded = canonical_json(value).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +def freeze_json_object(value: object) -> JsonObject: + """Validate and detach a JSON object, then recursively make it immutable.""" + plain = _copy_json(value, path="$", require_object=True) + return cast(JsonObject, _freeze(plain)) + + +def thaw_json(value: JsonValue) -> object: + """Return detached plain dict/list JSON data from an immutable value.""" + if isinstance(value, Mapping): + return {key: thaw_json(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [thaw_json(item) for item in value] + return value + + +def ensure_credential_free(value: JsonValue, *, path: str = "$") -> None: + """Reject credential-shaped keys without inspecting user text values.""" + _ensure_provenance_keys( + value, + path=path, + forbidden=_SENSITIVE_PROVENANCE_KEYS, + forbidden_compact=_SENSITIVE_PROVENANCE_KEYS_COMPACT, + kind="credential", + ) + + +def ensure_stable_provenance(value: JsonValue, *, path: str = "$") -> None: + """Reject credentials and process-instance fields from digest inputs.""" + ensure_credential_free(value, path=path) + _ensure_provenance_keys( + value, + path=path, + forbidden=_VOLATILE_PROVENANCE_KEYS, + forbidden_compact=_VOLATILE_PROVENANCE_KEYS_COMPACT, + kind="volatile", + ) + + +def _copy_json(value: object, *, path: str, require_object: bool) -> object: + if require_object and not isinstance(value, Mapping): + raise InvalidGraphError(f"{path} must be a JSON object") + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise InvalidGraphError(f"{path} contains a non-finite number") + return value + if isinstance(value, Mapping): + copied: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise InvalidGraphError(f"{path} contains a non-string object key") + copied[key] = _copy_json(item, path=f"{path}.{key}", require_object=False) + return copied + if isinstance(value, (list, tuple)): + return [_copy_json(item, path=f"{path}[{index}]", require_object=False) for index, item in enumerate(value)] + raise InvalidGraphError(f"{path} contains unsupported JSON value {type(value).__name__}") + + +def _freeze(value: object) -> JsonValue: + if isinstance(value, dict): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze(item) for item in value) + return cast(JsonScalar, value) + + +def _ensure_provenance_keys( + value: JsonValue, + *, + path: str, + forbidden: set[str], + forbidden_compact: set[str], + kind: str, +) -> None: + if isinstance(value, Mapping): + for key, item in value.items(): + normalized = key.lower().replace("-", "_") + if normalized in forbidden or normalized.replace("_", "") in forbidden_compact: + raise ValueError(f"{kind} provenance field {path}.{key} is forbidden") + _ensure_provenance_keys( + item, + path=f"{path}.{key}", + forbidden=forbidden, + forbidden_compact=forbidden_compact, + kind=kind, + ) + elif isinstance(value, (tuple, list)): + for index, item in enumerate(value): + _ensure_provenance_keys( + item, + path=f"{path}[{index}]", + forbidden=forbidden, + forbidden_compact=forbidden_compact, + kind=kind, + ) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/manifest.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/manifest.py new file mode 100644 index 000000000..434dfd07b --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/manifest.py @@ -0,0 +1,87 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Domain semantic resource manifest contracts.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Literal + +from hugegraph_llm.extraction_runtime.v1.json_value import ( + JsonObject, + digest_json, + ensure_stable_provenance, + freeze_json_object, +) + + +@dataclass(frozen=True) +class SemanticResourceV1: + name: str + content_digest: str + media_type: str = "text/plain" + + @classmethod + def from_text(cls, name: str, content: str, *, media_type: str = "text/plain") -> SemanticResourceV1: + encoded = content.encode("utf-8") + return cls(name=name, content_digest=f"sha256:{hashlib.sha256(encoded).hexdigest()}", media_type=media_type) + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("semantic resource name must not be empty") + if not self.content_digest.startswith("sha256:"): + raise ValueError("semantic resource digest must use sha256") + + +@dataclass(frozen=True) +class DomainSemanticManifestV1: + bundle_id: str + bundle_version: str + resources: tuple[SemanticResourceV1, ...] + semantics: JsonObject = field(default_factory=dict) + contract: Literal["domain-semantic-manifest/v1"] = "domain-semantic-manifest/v1" + + def __post_init__(self) -> None: + if not self.bundle_id: + raise ValueError("bundle_id must not be empty") + if not self.bundle_version: + raise ValueError("bundle_version must not be empty") + if len({resource.name for resource in self.resources}) != len(self.resources): + raise ValueError("semantic resource names must be unique") + semantics = freeze_json_object(self.semantics) + ensure_stable_provenance(semantics, path="$.semantics") + object.__setattr__(self, "semantics", semantics) + + def as_digest_input(self) -> JsonObject: + return freeze_json_object( + { + "contract": self.contract, + "bundle_id": self.bundle_id, + "bundle_version": self.bundle_version, + "resources": [ + { + "name": resource.name, + "content_digest": resource.content_digest, + "media_type": resource.media_type, + } + for resource in sorted(self.resources, key=lambda item: item.name) + ], + "semantics": self.semantics, + } + ) + + @property + def domain_semantic_digest(self) -> str: + return digest_json(self.as_digest_input()) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/resources.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/resources.py new file mode 100644 index 000000000..f656e3df6 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/resources.py @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Read-only access to versioned packaged runtime resources.""" + +from __future__ import annotations + +import json +from importlib.resources import files + +from hugegraph_llm.extraction_runtime.v1.json_value import JsonObject, freeze_json_object + + +def load_runtime_contract_resource() -> JsonObject: + """Load and validate the packaged runtime v1 descriptor.""" + resource = files("hugegraph_llm.extraction_runtime.resources").joinpath("runtime-contract-v1.json") + value = json.loads(resource.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise TypeError("runtime contract resource must be a JSON object") + if value.get("schema") != "hugegraph-ai/extraction-runtime-resource" or value.get("resource_version") != 1: + raise ValueError("unsupported runtime contract resource") + return freeze_json_object(value) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/review_loop.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/review_loop.py new file mode 100644 index 000000000..cff564f4f --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/review_loop.py @@ -0,0 +1,57 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic review and successful-fix budget accounting.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hugegraph_llm.extraction_runtime.v1.errors import BudgetExhaustedError + + +@dataclass(frozen=True) +class ReviewBudgetV1: + max_reviews: int + max_fixes: int + + def __post_init__(self) -> None: + if self.max_reviews < 0: + raise ValueError("max_reviews must be non-negative") + if self.max_fixes < 0: + raise ValueError("max_fixes must be non-negative") + + +@dataclass(frozen=True) +class ReviewBudgetStateV1: + budget: ReviewBudgetV1 + reviews_used: int = 0 + fixes_used: int = 0 + + @property + def can_review(self) -> bool: + return self.reviews_used < self.budget.max_reviews + + @property + def can_fix(self) -> bool: + return self.fixes_used < self.budget.max_fixes + + def consume_review(self) -> ReviewBudgetStateV1: + if not self.can_review: + raise BudgetExhaustedError("review budget exhausted") + return ReviewBudgetStateV1(self.budget, self.reviews_used + 1, self.fixes_used) + + def consume_fix(self) -> ReviewBudgetStateV1: + if not self.can_fix: + raise BudgetExhaustedError("fix budget exhausted") + return ReviewBudgetStateV1(self.budget, self.reviews_used, self.fixes_used + 1) diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/terminal.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/terminal.py new file mode 100644 index 000000000..5d070c85c --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/terminal.py @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Four-terminal semantic truth table.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hugegraph_llm.extraction_runtime.v1.contracts import GateDisposition, ReviewDisposition, TerminalKind + + +@dataclass(frozen=True) +class TerminalEvidenceV1: + safe_graph: bool = False + schema_valid: bool = False + identity_valid: bool = False + review: ReviewDisposition | None = None + gate: GateDisposition | None = None + quality_budget_exhausted: bool = False + technical_failure: str | None = None + + +@dataclass(frozen=True) +class TerminalResolutionV1: + kind: TerminalKind + reason_code: str + retryable: bool = False + + +def resolve_terminal(evidence: TerminalEvidenceV1) -> TerminalResolutionV1: + """Resolve semantic terminal state with technical and safety precedence.""" + if evidence.technical_failure: + return TerminalResolutionV1(TerminalKind.FAILED, evidence.technical_failure) + if evidence.review is ReviewDisposition.BLOCK: + return TerminalResolutionV1(TerminalKind.BLOCKED, "review_blocked") + if evidence.gate is GateDisposition.BLOCK: + return TerminalResolutionV1(TerminalKind.BLOCKED, "gate_blocked") + if not evidence.safe_graph: + return TerminalResolutionV1(TerminalKind.BLOCKED, "unsafe_graph") + if not evidence.schema_valid: + return TerminalResolutionV1(TerminalKind.BLOCKED, "schema_invalid") + if not evidence.identity_valid: + return TerminalResolutionV1(TerminalKind.BLOCKED, "identity_invalid") + if evidence.quality_budget_exhausted: + return TerminalResolutionV1(TerminalKind.CANDIDATE, "quality_budget_exhausted") + if evidence.gate is GateDisposition.HOLD: + return TerminalResolutionV1(TerminalKind.CANDIDATE, "gate_hold") + if evidence.review is ReviewDisposition.PASS and evidence.gate is GateDisposition.PASS: + return TerminalResolutionV1(TerminalKind.FINAL, "accepted") + return TerminalResolutionV1(TerminalKind.FAILED, "incomplete_terminal_evidence") diff --git a/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/trace.py b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/trace.py new file mode 100644 index 000000000..a351e58f3 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/extraction_runtime/v1/trace.py @@ -0,0 +1,78 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic runtime trace hash chain.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hugegraph_llm.extraction_runtime.v1.contracts import GraphSnapshotV1 +from hugegraph_llm.extraction_runtime.v1.json_value import ( + JsonObject, + digest_json, + ensure_credential_free, + freeze_json_object, +) + + +@dataclass(frozen=True) +class TraceEventV1: + sequence: int + stage: str + outcome: str + graph_revision: int | None + graph_digest: str | None + details: JsonObject + previous_head: str | None + event_digest: str + + +@dataclass(frozen=True) +class TraceRecorderV1: + events: tuple[TraceEventV1, ...] = () + + @property + def trace_head(self) -> str | None: + return self.events[-1].event_digest if self.events else None + + def append( + self, + stage: str, + outcome: str, + graph: GraphSnapshotV1 | None, + details: JsonObject | None = None, + ) -> TraceRecorderV1: + frozen_details = freeze_json_object(details or {}) + ensure_credential_free(frozen_details, path="$.details") + payload = { + "contract": "extraction-trace-event/v1", + "sequence": len(self.events), + "stage": stage, + "outcome": outcome, + "graph_revision": graph.revision if graph else None, + "graph_digest": graph.graph_digest if graph else None, + "details": frozen_details, + "previous_head": self.trace_head, + } + event = TraceEventV1( + sequence=len(self.events), + stage=stage, + outcome=outcome, + graph_revision=graph.revision if graph else None, + graph_digest=graph.graph_digest if graph else None, + details=frozen_details, + previous_head=self.trace_head, + event_digest=digest_json(payload), + ) + return TraceRecorderV1(self.events + (event,)) diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_batch.py b/hugegraph-llm/src/tests/extraction_runtime/test_batch.py new file mode 100644 index 000000000..6f882308e --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_batch.py @@ -0,0 +1,195 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from threading import Barrier, Event, Lock + +import pytest + +from hugegraph_llm.extraction_runtime.conformance import InventoryBundleV1, InventoryPolicyV1 +from hugegraph_llm.extraction_runtime.provider import ProviderResponseV1, ReplayEntryV1, ReplayProvider +from hugegraph_llm.extraction_runtime.v1 import ( + ExtractionEngineV1, + GateDisposition, + GraphStateV1, + NormalizedChunkV1, + RepairReason, + RepairRequestV1, + ReviewBudgetV1, + RunControlV1, + TerminalKind, + run_chunks_v1, +) + +pytestmark = pytest.mark.contract + + +def _chunk(ordinal: int) -> NormalizedChunkV1: + return NormalizedChunkV1( + document_id="inventory", chunk_id=f"chunk-{ordinal}", ordinal=ordinal, text=f"Item {ordinal}" + ) + + +def _prepare(chunk, bundle_type=InventoryBundleV1, policy=None): + template = InventoryBundleV1(ReplayProvider(()), policy) + initial = {"items": [{"sku": chunk.chunk_id, "count": 0}]} + repaired = {"items": [{"sku": chunk.chunk_id, "count": chunk.ordinal + 1}]} + snapshot = GraphStateV1().promote_initial(initial) + validation = template.validate_schema(snapshot, chunk) + identity = template.identify(snapshot, chunk) + review = template.review(snapshot, chunk, validation, identity) + repair_request = RepairRequestV1( + reason=RepairReason.REVIEW, + expected_graph_digest=snapshot.graph_digest, + context={"review_disposition": review.disposition.value, "review_findings": list(review.findings)}, + ) + entries = tuple( + ReplayEntryV1( + request.adaptation.requested_digest, + request.adaptation.effective_digest, + ProviderResponseV1(output={"graph": graph}, model=template.model), + ) + for request, graph in ( + (template.plan_extract(chunk), initial), + (template.plan_repair(snapshot, chunk, repair_request), repaired), + ) + ) + bundle = bundle_type(ReplayProvider(entries), policy) + control = RunControlV1( + budget=ReviewBudgetV1(max_reviews=2, max_fixes=1), provider_execution=bundle.provider_execution() + ) + return bundle, control + + +@pytest.mark.parametrize("workers", [1, 2, 3]) +def test_concurrency_limit_and_chunk_state_isolation(workers): + barrier = Barrier(workers, timeout=5) + lock = Lock() + active = 0 + peak = 0 + instances = [] + + class ConcurrentInventoryBundle(InventoryBundleV1): + def extract(self, chunk): + nonlocal active, peak + with lock: + active += 1 + peak = max(peak, active) + instances.append(self) + try: + barrier.wait() + return super().extract(chunk) + finally: + with lock: + active -= 1 + + chunks = tuple(_chunk(i) for i in range(workers * 2)) + results = run_chunks_v1( + chunks=iter(chunks), + prepare=lambda chunk: _prepare(chunk, ConcurrentInventoryBundle), + max_workers=workers, + ) + + assert peak == workers + assert active == 0 + assert len({id(bundle) for bundle in instances}) == len(chunks) + assert len({id(bundle.provider) for bundle in instances}) == len(chunks) + assert all(bundle.provider.remaining == 0 for bundle in instances) + assert tuple(item.chunk for item in results) == chunks + for item in results: + result = item.result + assert result.intent.kind is TerminalKind.FINAL + assert result.budget.reviews_used == 2 + assert result.budget.fixes_used == 1 + assert result.current_graph.revision == 1 + assert result.artifact.graph == {"items": ({"sku": item.chunk.chunk_id, "count": item.chunk.ordinal + 1},)} + assert [event.graph_revision for event in result.trace.events if event.stage == "review"] == [0, 1] + + +def test_results_keep_input_order_when_later_chunk_finishes_first(): + later_finished = Event() + gate_order = [] + chunks = (_chunk(9), _chunk(2), _chunk(5)) + + class OutOfOrderBundle(InventoryBundleV1): + def extract(self, chunk): + if chunk is chunks[0]: + assert later_finished.wait(timeout=5) + return super().extract(chunk) + + def final_gate(self, graph, chunk, validation, identity, review): + gate = super().final_gate(graph, chunk, validation, identity, review) + gate_order.append(chunk.chunk_id) + if chunk is chunks[1]: + later_finished.set() + return gate + + results = run_chunks_v1(chunks=chunks, prepare=lambda chunk: _prepare(chunk, OutOfOrderBundle), max_workers=2) + assert gate_order[0] == chunks[1].chunk_id + assert tuple(item.chunk for item in results) == chunks + assert all(item.result.intent.kind is TerminalKind.FINAL for item in results) + + +def test_batch_collects_all_terminal_kinds_despite_a_failed_chunk(): + class FailingInventoryBundle(InventoryBundleV1): + def extract(self, chunk): + if chunk.ordinal == 1: + raise RuntimeError("fixture provider failure") + return super().extract(chunk) + + def prepare(chunk): + disposition = {3: GateDisposition.HOLD, 4: GateDisposition.BLOCK}.get(chunk.ordinal, GateDisposition.PASS) + return _prepare(chunk, FailingInventoryBundle, InventoryPolicyV1(gate_disposition=disposition)) + + results = run_chunks_v1(chunks=(_chunk(i) for i in range(5)), prepare=prepare, max_workers=2) + assert [item.result.intent.kind for item in results] == [ + TerminalKind.FINAL, + TerminalKind.FAILED, + TerminalKind.FINAL, + TerminalKind.CANDIDATE, + TerminalKind.BLOCKED, + ] + assert results[1].result.intent.reason_code == "extract_failed" + assert results[1].result.artifact.graph is None + assert results[1].result.budget.reviews_used == 0 + + +def test_batch_preserves_single_chunk_artifacts(): + chunks = (_chunk(0), _chunk(1), _chunk(2)) + expected = [] + for chunk in chunks: + bundle, control = _prepare(chunk) + expected.append(ExtractionEngineV1().run(bundle=bundle, chunk=chunk, control=control).artifact) + for workers in (1, 3): + results = run_chunks_v1(chunks=chunks, prepare=_prepare, max_workers=workers) + assert [item.result.artifact for item in results] == expected + + +def test_empty_batch_does_not_prepare_chunks(): + def unexpected_prepare(chunk): + pytest.fail("empty batch must not invoke prepare") + + assert run_chunks_v1(chunks=(), prepare=unexpected_prepare) == () + + +def test_preparation_errors_propagate(): + def broken_prepare(chunk): + raise ValueError("invalid bundle configuration") + + with pytest.raises(ValueError, match="invalid bundle configuration"): + run_chunks_v1(chunks=(_chunk(0),), prepare=broken_prepare) + + +@pytest.mark.parametrize("workers", [0, -1]) +def test_invalid_concurrency_is_rejected(workers): + with pytest.raises(ValueError): + run_chunks_v1(chunks=(), prepare=_prepare, max_workers=workers) diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_contracts.py b/hugegraph-llm/src/tests/extraction_runtime/test_contracts.py new file mode 100644 index 000000000..664c1106a --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_contracts.py @@ -0,0 +1,132 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from dataclasses import FrozenInstanceError + +import pytest + +from hugegraph_llm.extraction_runtime.v1 import ( + FailureDisposition, + FailureOutcomeV1, + GateDisposition, + GateOutcomeV1, + GraphSnapshotV1, + IdentityOutcomeV1, + NormalizedChunkV1, + RepairOutcomeV1, + ReviewDisposition, + ReviewOutcomeV1, + TerminalArtifactBodyV1, + TerminalIntentV1, + TerminalKind, + ValidationOutcomeV1, + canonical_json, + digest_json, +) + +pytestmark = pytest.mark.contract + + +def test_control_contracts_are_typed_and_frozen() -> None: + chunk = NormalizedChunkV1( + document_id="inventory", + chunk_id="inventory-0", + ordinal=0, + text="two bolts", + visible_metadata={"source": "fixture"}, + input_digest="sha256:input", + ) + validation = ValidationOutcomeV1(valid=True) + identity = IdentityOutcomeV1(valid=True, identity={"keys": ["bolt"]}) + review = ReviewOutcomeV1( + disposition=ReviewDisposition.PASS, + expected_graph_digest="sha256:graph", + findings=({"code": "ok"},), + ) + repair = RepairOutcomeV1( + base_graph_digest="sha256:graph", + candidate_graph={"items": [{"name": "bolt"}]}, + patch={"replace": "items"}, + ) + gate = GateOutcomeV1( + disposition=GateDisposition.PASS, + expected_graph_digest="sha256:graph", + report={"accepted": True}, + ) + failure = FailureOutcomeV1( + disposition=FailureDisposition.FAILED, + reason_code="provider_protocol", + retryable=False, + ) + graph = {"items": [{"name": "bolt"}]} + graph_digest = digest_json(graph) + intent = TerminalIntentV1( + kind=TerminalKind.FINAL, + reason_code="accepted", + graph_revision=0, + graph_digest=graph_digest, + retryable=False, + ) + artifact = TerminalArtifactBodyV1( + intent=intent, + graph=graph, + review={"disposition": "pass"}, + final_gate={"disposition": "pass"}, + trace_head="sha256:trace", + run_fingerprint="sha256:run", + runtime_contract_digest="sha256:runtime", + domain_semantic_digest="sha256:domain", + provider_execution_digest="sha256:provider", + input_digest="sha256:input", + host_plan_digest=None, + ) + + assert chunk.contract == "normalized-chunk/v1" + assert validation.valid and identity.valid + assert review.disposition is ReviewDisposition.PASS + assert repair.patch == {"replace": "items"} + assert gate.disposition is GateDisposition.PASS + assert failure.disposition is FailureDisposition.FAILED + assert artifact.contract == "extraction-terminal-body/v1" + assert artifact.intent.kind is TerminalKind.FINAL + serialized = json.loads(canonical_json(artifact.as_json_object())) + assert serialized["intent"]["kind"] == "final" + assert serialized["graph"] == graph + with pytest.raises(FrozenInstanceError): + chunk.text = "mutated" # type: ignore[misc] + + +def test_graph_and_control_payloads_reject_credential_fields() -> None: + graph = {"vertices": [{"password": "not-a-real-password"}]} # pragma: allowlist secret + with pytest.raises(ValueError, match="credential"): + GraphSnapshotV1(revision=0, graph=graph, graph_digest=digest_json(graph)) + with pytest.raises(ValueError, match="credential"): + ReviewOutcomeV1( + disposition=ReviewDisposition.PASS, + expected_graph_digest="digest", + findings=({"authorization": "not-a-real-credential"},), + ) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"document_id": "", "chunk_id": "x", "ordinal": 0, "text": "x"}, "document_id"), + ({"document_id": "d", "chunk_id": "", "ordinal": 0, "text": "x"}, "chunk_id"), + ({"document_id": "d", "chunk_id": "x", "ordinal": -1, "text": "x"}, "ordinal"), + ], +) +def test_normalized_chunk_rejects_invalid_control_fields(kwargs: dict[str, object], message: str) -> None: + with pytest.raises(ValueError, match=message): + NormalizedChunkV1(**kwargs) # type: ignore[arg-type] diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_dependency_guard.py b/hugegraph-llm/src/tests/extraction_runtime/test_dependency_guard.py new file mode 100644 index 000000000..bcbf72014 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_dependency_guard.py @@ -0,0 +1,66 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.contract + + +def test_runtime_has_no_forbidden_domain_or_host_imports() -> None: + package = Path(__file__).parents[2] / "hugegraph_llm" / "extraction_runtime" + forbidden = ( + "hugegraph_llm.api", + "hugegraph_llm.car_graph_workflow", + "hugegraph_llm.flows", + "hugegraph_llm.nodes", + "hugegraph_llm.operators", + "pyhugegraph", + ) + violations: list[str] = [] + + for source in package.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + names: list[str] = [] + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module] + for name in names: + if name.startswith(forbidden): + violations.append(f"{source.relative_to(package)} imports {name}") + + assert violations == [] + + +def test_core_and_provider_do_not_import_the_inventory_conformance_domain() -> None: + package = Path(__file__).parents[2] / "hugegraph_llm" / "extraction_runtime" + violations: list[str] = [] + + for subtree in (package / "v1", package / "provider"): + for source in subtree.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module] + else: + names = [] + if any(name.startswith("hugegraph_llm.extraction_runtime.conformance") for name in names): + violations.append(str(source.relative_to(package))) + + assert violations == [] diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_engine.py b/hugegraph-llm/src/tests/extraction_runtime/test_engine.py new file mode 100644 index 000000000..578a75ff9 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_engine.py @@ -0,0 +1,186 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import cast +from unittest.mock import patch + +import pytest + +from hugegraph_llm.extraction_runtime.v1 import ( + DomainSemanticManifestV1, + ExtractionEngineV1, + GateDisposition, + GateOutcomeV1, + GraphSnapshotV1, + IdentityOutcomeV1, + JsonObject, + NormalizedChunkV1, + RepairOutcomeV1, + RepairRequestV1, + ReviewBudgetV1, + ReviewDisposition, + ReviewOutcomeV1, + RunControlV1, + SemanticResourceV1, + TerminalKind, + ValidationOutcomeV1, +) +from hugegraph_llm.extraction_runtime.v1 import engine as engine_module + +pytestmark = pytest.mark.unit + + +class ScriptedInventoryBundle: + def __init__(self, *, repair: bool = False, stale_repair: bool = False) -> None: + self.repair_enabled = repair + self.stale_repair = stale_repair + self.calls: list[str] = [] + self.review_count = 0 + + def semantic_manifest(self) -> DomainSemanticManifestV1: + return DomainSemanticManifestV1( + bundle_id="inventory", + bundle_version="1", + resources=(SemanticResourceV1.from_text("prompt", "extract inventory"),), + semantics={"identity": "sku"}, + ) + + def extract(self, chunk: NormalizedChunkV1) -> JsonObject: + self.calls.append("extract") + return {"items": [{"sku": "A", "count": 2}]} + + def validate_schema(self, graph: GraphSnapshotV1, chunk: NormalizedChunkV1) -> ValidationOutcomeV1: + self.calls.append("schema") + return ValidationOutcomeV1(valid=True) + + def identify(self, graph: GraphSnapshotV1, chunk: NormalizedChunkV1) -> IdentityOutcomeV1: + self.calls.append("identity") + item = cast(list[JsonObject], graph.graph["items"])[0] + return IdentityOutcomeV1(valid=True, identity={"keys": [item["sku"]]}) + + def review( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + validation: ValidationOutcomeV1, + identity: IdentityOutcomeV1, + ) -> ReviewOutcomeV1: + self.calls.append("review") + self.review_count += 1 + disposition = ( + ReviewDisposition.FIX if self.repair_enabled and self.review_count == 1 else ReviewDisposition.PASS + ) + return ReviewOutcomeV1(disposition=disposition, expected_graph_digest=graph.graph_digest) + + def repair( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + request: RepairRequestV1, + ) -> RepairOutcomeV1: + self.calls.append("repair") + base = "sha256:stale" if self.stale_repair else graph.graph_digest + return RepairOutcomeV1( + base_graph_digest=base, + candidate_graph={"items": [{"sku": "A", "count": 3}]}, + patch={"count": 3}, + ) + + def final_gate( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + validation: ValidationOutcomeV1, + identity: IdentityOutcomeV1, + review: ReviewOutcomeV1, + ) -> GateOutcomeV1: + self.calls.append("gate") + return GateOutcomeV1( + disposition=GateDisposition.PASS, + expected_graph_digest=graph.graph_digest, + report={"accepted": True}, + ) + + +def _chunk() -> NormalizedChunkV1: + return NormalizedChunkV1(document_id="inventory", chunk_id="inventory-0", ordinal=0, text="two bolts") + + +def _control() -> RunControlV1: + return RunControlV1( + budget=ReviewBudgetV1(max_reviews=2, max_fixes=1), + provider_execution={"adapter": "replay/v1", "model": "inventory-script"}, + ) + + +def test_engine_runs_fixed_no_fix_path_and_returns_uncommitted_artifact() -> None: + bundle = ScriptedInventoryBundle() + result = ExtractionEngineV1().run(bundle=bundle, chunk=_chunk(), control=_control()) + + assert bundle.calls == ["extract", "schema", "identity", "review", "gate"] + assert result.intent.kind is TerminalKind.FINAL + assert result.current_graph is not None and result.current_graph.revision == 0 + assert result.artifact.intent == result.intent + assert result.artifact.graph == result.current_graph.graph + assert result.artifact.trace_head == result.trace.trace_head + assert result.artifact.run_fingerprint == result.fingerprints.run_fingerprint + + +def test_promoted_repair_is_used_by_every_later_stage_and_artifact() -> None: + bundle = ScriptedInventoryBundle(repair=True) + result = ExtractionEngineV1().run(bundle=bundle, chunk=_chunk(), control=_control()) + + assert bundle.calls == ["extract", "schema", "identity", "review", "repair", "schema", "identity", "review", "gate"] + assert result.intent.kind is TerminalKind.FINAL + assert result.current_graph is not None and result.current_graph.revision == 1 + assert result.intent.graph_digest == result.current_graph.graph_digest + assert result.artifact.graph == result.current_graph.graph + assert result.budget.reviews_used == 2 and result.budget.fixes_used == 1 + gate_event = next(event for event in result.trace.events if event.stage == "final_gate") + assert gate_event.graph_revision == 1 + assert gate_event.graph_digest == result.current_graph.graph_digest + + +def test_stale_repair_is_failed_without_replacing_current_graph() -> None: + bundle = ScriptedInventoryBundle(repair=True, stale_repair=True) + result = ExtractionEngineV1().run(bundle=bundle, chunk=_chunk(), control=_control()) + + assert result.intent.kind is TerminalKind.FAILED + assert result.intent.reason_code == "repair_failed" + assert result.current_graph is not None and result.current_graph.revision == 0 + assert result.budget.fixes_used == 0 + assert result.diagnostics[-1].code == "repair_failed" + + +def test_artifact_construction_failure_becomes_a_failed_terminal() -> None: + bundle = ScriptedInventoryBundle() + real_builder = engine_module.build_terminal_artifact_body + calls = 0 + + def fail_once(**kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise ValueError("malformed artifact") + return real_builder(**kwargs) + + with patch.object(engine_module, "build_terminal_artifact_body", side_effect=fail_once): + result = ExtractionEngineV1().run(bundle=bundle, chunk=_chunk(), control=_control()) + + assert result.intent.kind is TerminalKind.FAILED + assert result.intent.reason_code == "artifact_construction_failed" + assert result.current_graph is not None and result.current_graph.revision == 0 + assert result.diagnostics[-1].stage == "artifact" + assert result.diagnostics[-1].details["exception_type"] == "ValueError" diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_fingerprint.py b/hugegraph-llm/src/tests/extraction_runtime/test_fingerprint.py new file mode 100644 index 000000000..ac57109da --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_fingerprint.py @@ -0,0 +1,130 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from hugegraph_llm.extraction_runtime.v1 import ( + DomainSemanticManifestV1, + JsonObject, + NormalizedChunkV1, + SemanticResourceV1, + compose_run_fingerprint, +) + +pytestmark = pytest.mark.unit + + +def _manifest(prompt: str = "extract inventory") -> DomainSemanticManifestV1: + return DomainSemanticManifestV1( + bundle_id="inventory", + bundle_version="1", + resources=(SemanticResourceV1.from_text("prompt", prompt),), + semantics={"identity": "sku", "review": "counts-positive"}, + ) + + +def _chunk(text: str = "two bolts") -> NormalizedChunkV1: + return NormalizedChunkV1( + document_id="inventory", + chunk_id="inventory-0", + ordinal=0, + text=text, + visible_metadata={"source": "fixture"}, + ) + + +def test_fingerprint_layers_change_only_for_their_defined_inputs() -> None: + baseline = compose_run_fingerprint( + manifest=_manifest(), + provider_execution={"adapter": "test/v1", "model": "replay"}, + chunk=_chunk(), + host_plan={"chunker": "fixture/v1"}, + ) + provider_changed = compose_run_fingerprint( + manifest=_manifest(), + provider_execution={"adapter": "test/v1", "model": "replay-2"}, + chunk=_chunk(), + host_plan={"chunker": "fixture/v1"}, + ) + domain_changed = compose_run_fingerprint( + manifest=_manifest("extract stock"), + provider_execution={"adapter": "test/v1", "model": "replay"}, + chunk=_chunk(), + host_plan={"chunker": "fixture/v1"}, + ) + host_changed = compose_run_fingerprint( + manifest=_manifest(), + provider_execution={"adapter": "test/v1", "model": "replay"}, + chunk=_chunk(), + host_plan={"chunker": "fixture/v2"}, + ) + + assert baseline.domain_semantic_digest == provider_changed.domain_semantic_digest + assert baseline.input_digest == provider_changed.input_digest + assert baseline.provider_execution_digest != provider_changed.provider_execution_digest + assert baseline.run_fingerprint != provider_changed.run_fingerprint + + assert baseline.provider_execution_digest == domain_changed.provider_execution_digest + assert baseline.domain_semantic_digest != domain_changed.domain_semantic_digest + assert baseline.run_fingerprint != domain_changed.run_fingerprint + + assert baseline.domain_semantic_digest == host_changed.domain_semantic_digest + assert baseline.host_plan_digest != host_changed.host_plan_digest + assert baseline.run_fingerprint != host_changed.run_fingerprint + + +def test_supplied_input_digest_must_bind_the_normalized_chunk() -> None: + chunk = NormalizedChunkV1( + document_id="inventory", + chunk_id="inventory-0", + ordinal=0, + text="two bolts", + input_digest="sha256:not-the-chunk", + ) + with pytest.raises(ValueError, match="input_digest"): + compose_run_fingerprint( + manifest=_manifest(), + provider_execution={"model": "replay"}, + chunk=chunk, + ) + + +@pytest.mark.parametrize( + "provider_execution", + [ + {"model": "replay", "api_key": "secret"}, # pragma: allowlist secret + {"model": "replay", "headers": {"Authorization": "secret"}}, + {"model": "replay", "run_id": "run-1"}, + {"model": "replay", "timing": {"duration": 1.5}}, + ], +) +def test_fingerprint_rejects_credentials_and_volatile_execution_fields( + provider_execution: JsonObject, +) -> None: + with pytest.raises(ValueError, match="credential|volatile"): + compose_run_fingerprint( + manifest=_manifest(), + provider_execution=provider_execution, + chunk=_chunk(), + ) + + +def test_normalized_chunk_rejects_credential_metadata() -> None: + with pytest.raises(ValueError, match="credential"): + NormalizedChunkV1( + document_id="inventory", + chunk_id="inventory-0", + ordinal=0, + text="two bolts", + visible_metadata={"authorization": "secret"}, + ) diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_graph_state.py b/hugegraph-llm/src/tests/extraction_runtime/test_graph_state.py new file mode 100644 index 000000000..f7642bc48 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_graph_state.py @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import pytest + +from hugegraph_llm.extraction_runtime.v1 import GraphStateV1, InvalidGraphError, StaleGraphError + +pytestmark = pytest.mark.unit + + +def test_graph_promotion_is_immutable_and_monotonic() -> None: + candidate = {"items": [{"name": "bolt", "count": 2}]} + state = GraphStateV1() + + initial = state.promote_initial(candidate) + candidate["items"][0]["name"] = "mutated" + + assert initial.revision == 0 + assert initial.graph["items"][0]["name"] == "bolt" # type: ignore[index] + with pytest.raises(TypeError): + initial.graph["items"][0]["name"] = "mutated" # type: ignore[index] + + repaired = state.promote_repair( + {"items": [{"name": "bolt", "count": 3}]}, + expected_base_digest=initial.graph_digest, + ) + assert repaired.revision == 1 + assert state.current is repaired + assert repaired.graph_digest != initial.graph_digest + + +def test_stale_or_invalid_repair_never_mutates_current_graph() -> None: + state = GraphStateV1() + initial = state.promote_initial({"items": [{"name": "bolt"}]}) + + with pytest.raises(StaleGraphError): + state.promote_repair({"items": []}, expected_base_digest="sha256:stale") + assert state.current is initial + + with pytest.raises(InvalidGraphError): + state.promote_repair({"score": math.nan}, expected_base_digest=initial.graph_digest) + assert state.current is initial + + +def test_initial_graph_requires_a_json_object() -> None: + state = GraphStateV1() + with pytest.raises(InvalidGraphError): + state.promote_initial([{"name": "bolt"}]) # type: ignore[arg-type] + assert state.current is None diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_inventory_conformance.py b/hugegraph-llm/src/tests/extraction_runtime/test_inventory_conformance.py new file mode 100644 index 000000000..94a94f665 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_inventory_conformance.py @@ -0,0 +1,295 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import cast + +import pytest + +from hugegraph_llm.extraction_runtime.conformance import InventoryBundleV1, InventoryPolicyV1 +from hugegraph_llm.extraction_runtime.provider import ( + ProviderResponseV1, + ReplayEntryV1, + ReplayProvider, +) +from hugegraph_llm.extraction_runtime.v1 import ( + ExtractionEngineV1, + GateDisposition, + GraphSnapshotV1, + GraphStateV1, + JsonObject, + NormalizedChunkV1, + RepairOutcomeV1, + RepairReason, + RepairRequestV1, + ReviewBudgetV1, + RunControlV1, + TerminalKind, +) + +pytestmark = pytest.mark.contract + + +def _chunk() -> NormalizedChunkV1: + return NormalizedChunkV1( + document_id="inventory", + chunk_id="inventory-0", + ordinal=0, + text="Inventory contains two bolts.", + visible_metadata={"source": "redistributable-fixture"}, + ) + + +def _bundle( + initial_graph: JsonObject, + *, + repaired_graph: JsonObject | None = None, + policy: InventoryPolicyV1 | None = None, + bundle_type: type[InventoryBundleV1] = InventoryBundleV1, +) -> InventoryBundleV1: + chunk = _chunk() + template = InventoryBundleV1(ReplayProvider(()), policy) + extract_request = template.plan_extract(chunk) + entries = [ + ReplayEntryV1( + extract_request.adaptation.requested_digest, + extract_request.adaptation.effective_digest, + ProviderResponseV1(output={"graph": initial_graph}, model=template.model, model_revision="fixture-1"), + ) + ] + if repaired_graph is not None: + snapshot = GraphStateV1().promote_initial(initial_graph) + validation = template.validate_schema(snapshot, chunk) + identity = template.identify(snapshot, chunk) + review = template.review(snapshot, chunk, validation, identity) + request = RepairRequestV1( + reason=RepairReason.REVIEW, + expected_graph_digest=snapshot.graph_digest, + context={ + "review_disposition": review.disposition.value, + "review_findings": list(review.findings), + }, + ) + repair_request = template.plan_repair(snapshot, chunk, request) + entries.append( + ReplayEntryV1( + repair_request.adaptation.requested_digest, + repair_request.adaptation.effective_digest, + ProviderResponseV1( + output={"graph": repaired_graph, "patch": {"op": "replace-count"}}, + model=template.model, + model_revision="fixture-1", + ), + ) + ) + return bundle_type(ReplayProvider(tuple(entries)), policy) + + +def _run(bundle: InventoryBundleV1, *, reviews: int = 2, fixes: int = 1): + return ExtractionEngineV1().run( + bundle=bundle, + chunk=_chunk(), + control=RunControlV1( + budget=ReviewBudgetV1(max_reviews=reviews, max_fixes=fixes), + provider_execution=bundle.provider_execution(), + host_plan={"source_mapping": "fixture/v1"}, + ), + ) + + +def test_inventory_no_fix_conformance_is_final_and_fully_bound() -> None: + bundle = _bundle({"items": [{"sku": "BOLT", "count": 2}]}) + result = _run(bundle) + + assert result.intent.kind is TerminalKind.FINAL + assert result.current_graph is not None and result.current_graph.revision == 0 + assert [event.stage for event in result.trace.events] == [ + "extract", + "schema", + "identity", + "review", + "final_gate", + "terminal", + ] + assert result.artifact.graph == result.current_graph.graph + assert result.intent.graph_digest == result.current_graph.graph_digest + assert result.artifact.trace_head == result.trace.trace_head + assert result.artifact.run_fingerprint == result.fingerprints.run_fingerprint + assert result.artifact.domain_semantic_digest == result.fingerprints.domain_semantic_digest + assert result.artifact.provider_execution_digest == result.fingerprints.provider_execution_digest + assert result.artifact.input_digest == result.fingerprints.input_digest + assert cast(ReplayProvider, bundle.provider).remaining == 0 + + +def test_inventory_post_fix_conformance_uses_repaired_current_graph_everywhere() -> None: + bundle = _bundle( + {"items": [{"sku": "BOLT", "count": 0}]}, + repaired_graph={"items": [{"sku": "BOLT", "count": 2}]}, + policy=InventoryPolicyV1(minimum_count=1), + ) + result = _run(bundle) + + assert result.intent.kind is TerminalKind.FINAL + assert result.current_graph is not None and result.current_graph.revision == 1 + assert result.intent.graph_digest == result.current_graph.graph_digest + assert result.artifact.graph == result.current_graph.graph + assert result.artifact.intent.graph_revision == 1 + assert result.budget.reviews_used == 2 and result.budget.fixes_used == 1 + assert [ + event.graph_revision for event in result.trace.events if event.stage in {"schema", "identity", "review"} + ] == [ + 0, + 0, + 0, + 1, + 1, + 1, + ] + assert cast(ReplayProvider, bundle.provider).remaining == 0 + + +@pytest.mark.parametrize( + ("bundle", "reviews", "fixes", "kind", "reason"), + [ + ( + _bundle({"items": [{"sku": "BOLT", "count": 2}]}), + 0, + 1, + TerminalKind.CANDIDATE, + "quality_budget_exhausted", + ), + ( + _bundle( + {"items": [{"sku": "BOLT", "count": 0}]}, + policy=InventoryPolicyV1(minimum_count=1), + ), + 1, + 0, + TerminalKind.CANDIDATE, + "quality_budget_exhausted", + ), + ( + _bundle( + {"items": [{"sku": "BANNED", "count": 2}]}, + policy=InventoryPolicyV1(blocked_skus=("BANNED",)), + ), + 1, + 1, + TerminalKind.BLOCKED, + "review_blocked", + ), + ( + _bundle( + {"items": [{"sku": "BOLT", "count": 2}]}, + policy=InventoryPolicyV1(gate_disposition=GateDisposition.HOLD), + ), + 1, + 1, + TerminalKind.CANDIDATE, + "gate_hold", + ), + ( + _bundle( + {"items": [{"sku": "BOLT", "count": 2}]}, + policy=InventoryPolicyV1(gate_disposition=GateDisposition.BLOCK), + ), + 1, + 1, + TerminalKind.BLOCKED, + "gate_blocked", + ), + ], +) +def test_inventory_terminal_and_budget_matrix( + bundle: InventoryBundleV1, + reviews: int, + fixes: int, + kind: TerminalKind, + reason: str, +) -> None: + result = _run(bundle, reviews=reviews, fixes=fixes) + assert result.intent.kind is kind + assert result.intent.reason_code == reason + + +@pytest.mark.parametrize( + ("graph", "reason"), + [ + ({"items": "not-a-list"}, "schema_invalid"), + ({"items": [{"sku": "A", "count": 1}, {"sku": "A", "count": 2}]}, "identity_invalid"), + ], +) +def test_unsafe_inventory_graphs_are_blocked_without_fix_budget(graph: JsonObject, reason: str) -> None: + result = _run(_bundle(graph), fixes=0) + assert result.intent.kind is TerminalKind.BLOCKED + assert result.intent.reason_code == reason + + +class StaleRepairInventoryBundle(InventoryBundleV1): + def repair( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + request: RepairRequestV1, + ) -> RepairOutcomeV1: + outcome = super().repair(graph, chunk, request) + return RepairOutcomeV1( + base_graph_digest="sha256:stale", + candidate_graph=outcome.candidate_graph, + patch=outcome.patch, + ) + + +class MalformedRepairInventoryBundle(InventoryBundleV1): + def repair( + self, + graph: GraphSnapshotV1, + chunk: NormalizedChunkV1, + request: RepairRequestV1, + ) -> RepairOutcomeV1: + super().repair(graph, chunk, request) + return RepairOutcomeV1( + base_graph_digest=graph.graph_digest, + candidate_graph=cast(JsonObject, {"items": [{"sku": "BOLT", "count": object()}]}), + ) + + +@pytest.mark.parametrize("bundle_type", [StaleRepairInventoryBundle, MalformedRepairInventoryBundle]) +def test_stale_and_malformed_inventory_repairs_fail_without_promotion( + bundle_type: type[InventoryBundleV1], +) -> None: + bundle = _bundle( + {"items": [{"sku": "BOLT", "count": 0}]}, + repaired_graph={"items": [{"sku": "BOLT", "count": 2}]}, + policy=InventoryPolicyV1(minimum_count=1), + bundle_type=bundle_type, + ) + result = _run(bundle) + + assert result.intent.kind is TerminalKind.FAILED + assert result.intent.reason_code == "repair_failed" + assert result.current_graph is not None and result.current_graph.revision == 0 + assert result.budget.fixes_used == 0 + assert result.diagnostics[-1].stage == "repair" + + +def test_provider_failure_is_a_failed_terminal_without_current_graph() -> None: + bundle = InventoryBundleV1(ReplayProvider(())) + result = _run(bundle) + + assert result.intent.kind is TerminalKind.FAILED + assert result.intent.reason_code == "extract_failed" + assert result.current_graph is None + assert result.artifact.graph is None + assert result.diagnostics[-1].details["exception_type"] == "ReplayExhaustedError" diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_packaging_compatibility.py b/hugegraph-llm/src/tests/extraction_runtime/test_packaging_compatibility.py new file mode 100644 index 000000000..2622e5ce8 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_packaging_compatibility.py @@ -0,0 +1,75 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ast +from pathlib import Path + +import pytest + +from hugegraph_llm.api.models.graph_extract_requests import GraphExtractRequest +from hugegraph_llm.extraction_runtime.v1 import canonical_json +from hugegraph_llm.extraction_runtime.v1.fingerprint import RUNTIME_CONTRACT +from hugegraph_llm.extraction_runtime.v1.resources import load_runtime_contract_resource + +pytestmark = pytest.mark.contract + + +def test_runtime_contract_resource_matches_implementation() -> None: + resource = load_runtime_contract_resource() + assert canonical_json(resource["runtime_contract"]) == canonical_json(RUNTIME_CONTRACT) + assert resource["public_integration"] == "none" + assert tuple(resource["terminal_kinds"]) == ("final", "candidate", "blocked", "failed") + + +def test_graph_extract_route_and_defaults_remain_unchanged() -> None: + package = Path(__file__).parents[2] / "hugegraph_llm" + source = (package / "api" / "graph_extract_api.py").read_text(encoding="utf-8") + assert ( + '@router.post("/graph/extract", status_code=status.HTTP_200_OK, response_model=GraphExtractResponse)' in source + ) + assert "/extraction-jobs" not in source + + request = GraphExtractRequest(texts="one bolt", schema={"vertexlabels": [], "edgelabels": []}) + assert request.extract_type == "property_graph" + assert request.language == "zh" + assert request.split_type == "document" + assert request.include_meta is False + + +def test_existing_scheduler_still_owns_graph_extract_flow() -> None: + package = Path(__file__).parents[2] / "hugegraph_llm" + source = (package / "flows" / "scheduler.py").read_text(encoding="utf-8") + assert "from hugegraph_llm.flows.graph_extract import GraphExtractFlow" in source + assert "self.pipeline_pool[FlowName.GRAPH_EXTRACT]" in source + assert '"flow": GraphExtractFlow()' in source + + +def test_production_modules_do_not_import_the_dormant_runtime() -> None: + package = Path(__file__).parents[2] / "hugegraph_llm" + violations: list[str] = [] + + for source in package.rglob("*.py"): + if "extraction_runtime" in source.relative_to(package).parts: + continue + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module] + else: + names = [] + if any(name.startswith("hugegraph_llm.extraction_runtime") for name in names): + violations.append(str(source.relative_to(package))) + + assert violations == [] diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_provider_dialect.py b/hugegraph-llm/src/tests/extraction_runtime/test_provider_dialect.py new file mode 100644 index 000000000..086c2893e --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_provider_dialect.py @@ -0,0 +1,125 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from hugegraph_llm.extraction_runtime.provider import ( + AdaptationAction, + ProviderCapabilitiesV1, + ProviderDialectV1, + ProviderMessageV1, + ProviderNeutralRequestV1, + RetryPolicyV1, + UnsupportedProviderParameterError, +) + +pytestmark = pytest.mark.contract + + +def _request() -> ProviderNeutralRequestV1: + return ProviderNeutralRequestV1( + stage="extract", + model="inventory-replay", + messages=(ProviderMessageV1(role="user", content="extract two bolts"),), + max_output_tokens=512, + temperature=0.0, + reasoning_effort="high", + thinking={"type": "enabled", "budget_tokens": 256}, + tools=( + { + "type": "function", + "function": { + "name": "emit_graph", + "parameters": {"type": "object", "properties": {"items": {"type": "array"}}}, + }, + }, + ), + response_schema={"type": "object", "required": ["items"]}, + strict_schema=True, + parallel_tool_calls=False, + optional_parameters={"seed": 7, "service_tier": "default"}, + timeout_seconds=30.0, + retry_policy=RetryPolicyV1(max_attempts=2, backoff_seconds=0.5), + ) + + +def test_supported_parameters_are_preserved_in_credential_free_payload() -> None: + effective = ProviderDialectV1().plan( + _request(), + ProviderCapabilitiesV1( + reasoning_effort=True, + thinking=True, + structured_tools=True, + strict_schema=True, + parallel_tool_calls=True, + optional_parameters=("seed", "service_tier"), + ), + ) + + assert effective.payload["reasoning_effort"] == "high" + assert effective.payload["thinking"] == {"type": "enabled", "budget_tokens": 256} + assert effective.payload["strict_schema"] is True + assert effective.payload["parallel_tool_calls"] is False + assert effective.payload["seed"] == 7 + assert effective.payload["service_tier"] == "default" + assert "api_key" not in effective.payload + assert "authorization" not in effective.payload + assert all(decision.action is AdaptationAction.KEPT for decision in effective.adaptation.decisions) + assert effective.adaptation.effective_digest + assert effective.adaptation.requested_digest + + +def test_unsupported_optional_parameters_are_recorded_and_removed() -> None: + effective = ProviderDialectV1().plan( + _request(), + ProviderCapabilitiesV1(structured_tools=True), + ) + + assert "reasoning_effort" not in effective.payload + assert "thinking" not in effective.payload + assert effective.payload["strict_schema"] is False + assert "parallel_tool_calls" not in effective.payload + assert "seed" not in effective.payload + assert "service_tier" not in effective.payload + decisions = {decision.parameter: decision for decision in effective.adaptation.decisions} + assert decisions["reasoning_effort"].action is AdaptationAction.DROPPED + assert decisions["thinking"].reason_code == "unsupported_optional_parameter" + assert decisions["strict_schema"].action is AdaptationAction.DOWNGRADED + assert decisions["parallel_tool_calls"].action is AdaptationAction.DROPPED + assert decisions["seed"].action is AdaptationAction.DROPPED + + +def test_structured_tools_fail_closed_when_provider_cannot_execute_them() -> None: + with pytest.raises(UnsupportedProviderParameterError, match="structured_tools"): + ProviderDialectV1().plan(_request(), ProviderCapabilitiesV1()) + + +@pytest.mark.parametrize("name", ["api_key", "authorization", "password", "access_token", "cookie"]) +def test_request_rejects_credential_shaped_optional_parameters(name: str) -> None: + with pytest.raises(ValueError, match="credential"): + ProviderNeutralRequestV1( + stage="extract", + model="replay", + messages=(ProviderMessageV1(role="user", content="hello"),), + optional_parameters={name: "secret"}, + ) + + +def test_optional_parameters_cannot_override_typed_request_fields() -> None: + with pytest.raises(ValueError, match="collides"): + ProviderNeutralRequestV1( + stage="extract", + model="replay", + messages=(ProviderMessageV1(role="user", content="hello"),), + optional_parameters={"model": "different-model"}, + ) diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_replay_provider.py b/hugegraph-llm/src/tests/extraction_runtime/test_replay_provider.py new file mode 100644 index 000000000..201a4b9c1 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_replay_provider.py @@ -0,0 +1,89 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from hugegraph_llm.extraction_runtime.provider import ( + ProviderCapabilitiesV1, + ProviderDialectV1, + ProviderMessageV1, + ProviderNeutralRequestV1, + ProviderResponseV1, + ReplayEntryV1, + ReplayExhaustedError, + ReplayMismatchError, + ReplayProvider, +) + +pytestmark = pytest.mark.unit + + +def _effective(prompt: str = "extract two bolts"): + return ProviderDialectV1().plan( + ProviderNeutralRequestV1( + stage="extract", + model="inventory-replay", + messages=(ProviderMessageV1(role="user", content=prompt),), + temperature=0.0, + ), + ProviderCapabilitiesV1(), + ) + + +def test_replay_provider_matches_effective_payload_and_captures_evidence() -> None: + effective = _effective() + response = ProviderResponseV1( + output={"items": [{"sku": "A", "count": 2}]}, + model="inventory-replay", + model_revision="fixture-1", + usage={"input_tokens": 3, "output_tokens": 8}, + ) + provider = ReplayProvider( + ( + ReplayEntryV1( + effective.adaptation.requested_digest, + effective.adaptation.effective_digest, + response, + ), + ) + ) + + actual = provider.execute(effective) + + assert actual == response + assert actual.response_digest + assert provider.effective_requests == (effective,) + assert provider.remaining == 0 + assert provider.effective_requests[0].payload == effective.payload + + with pytest.raises(ReplayExhaustedError): + provider.execute(effective) + + +def test_replay_provider_fails_closed_on_effective_request_mismatch() -> None: + expected = _effective() + actual = _effective("extract three nuts") + provider = ReplayProvider( + ( + ReplayEntryV1( + expected.adaptation.requested_digest, + expected.adaptation.effective_digest, + ProviderResponseV1(output={"items": []}, model="inventory-replay"), + ), + ) + ) + + with pytest.raises(ReplayMismatchError, match="request digest"): + provider.execute(actual) + assert provider.remaining == 1 + assert provider.effective_requests == () diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_review_budget.py b/hugegraph-llm/src/tests/extraction_runtime/test_review_budget.py new file mode 100644 index 000000000..f20a428d1 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_review_budget.py @@ -0,0 +1,41 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from hugegraph_llm.extraction_runtime.v1 import BudgetExhaustedError, ReviewBudgetStateV1, ReviewBudgetV1 + +pytestmark = pytest.mark.unit + + +def test_review_and_fix_budget_are_deterministic_and_immutable() -> None: + state = ReviewBudgetStateV1(ReviewBudgetV1(max_reviews=2, max_fixes=1)) + after_review = state.consume_review() + after_fix = after_review.consume_fix() + exhausted = after_fix.consume_review() + + assert state.reviews_used == 0 and state.fixes_used == 0 + assert exhausted.reviews_used == 2 and exhausted.fixes_used == 1 + assert not exhausted.can_review + assert not exhausted.can_fix + with pytest.raises(BudgetExhaustedError, match="review"): + exhausted.consume_review() + with pytest.raises(BudgetExhaustedError, match="fix"): + exhausted.consume_fix() + + +@pytest.mark.parametrize("field", ["max_reviews", "max_fixes"]) +def test_budget_rejects_negative_limits(field: str) -> None: + values = {"max_reviews": 1, "max_fixes": 1, field: -1} + with pytest.raises(ValueError, match=field): + ReviewBudgetV1(**values) diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_terminal.py b/hugegraph-llm/src/tests/extraction_runtime/test_terminal.py new file mode 100644 index 000000000..445a464fe --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_terminal.py @@ -0,0 +1,104 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from hugegraph_llm.extraction_runtime.v1 import ( + GateDisposition, + ReviewDisposition, + TerminalEvidenceV1, + TerminalKind, + resolve_terminal, +) + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize( + ("evidence", "kind", "reason"), + [ + (TerminalEvidenceV1(technical_failure="provider_protocol"), TerminalKind.FAILED, "provider_protocol"), + ( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.BLOCK, + ), + TerminalKind.BLOCKED, + "review_blocked", + ), + ( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.PASS, + gate=GateDisposition.BLOCK, + ), + TerminalKind.BLOCKED, + "gate_blocked", + ), + (TerminalEvidenceV1(safe_graph=False), TerminalKind.BLOCKED, "unsafe_graph"), + ( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.FIX, + quality_budget_exhausted=True, + ), + TerminalKind.CANDIDATE, + "quality_budget_exhausted", + ), + ( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.PASS, + gate=GateDisposition.HOLD, + ), + TerminalKind.CANDIDATE, + "gate_hold", + ), + ( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.PASS, + gate=GateDisposition.PASS, + ), + TerminalKind.FINAL, + "accepted", + ), + ], +) +def test_terminal_truth_table(evidence: TerminalEvidenceV1, kind: TerminalKind, reason: str) -> None: + resolution = resolve_terminal(evidence) + assert resolution.kind is kind + assert resolution.reason_code == reason + + +def test_incomplete_terminal_evidence_is_a_runtime_failure() -> None: + resolution = resolve_terminal( + TerminalEvidenceV1( + safe_graph=True, + schema_valid=True, + identity_valid=True, + review=ReviewDisposition.PASS, + ) + ) + assert resolution.kind is TerminalKind.FAILED + assert resolution.reason_code == "incomplete_terminal_evidence" diff --git a/hugegraph-llm/src/tests/extraction_runtime/test_trace_artifacts.py b/hugegraph-llm/src/tests/extraction_runtime/test_trace_artifacts.py new file mode 100644 index 000000000..619e58cd3 --- /dev/null +++ b/hugegraph-llm/src/tests/extraction_runtime/test_trace_artifacts.py @@ -0,0 +1,102 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from hugegraph_llm.extraction_runtime.v1 import ( + FingerprintLayersV1, + GraphStateV1, + RuntimeInvariantError, + TerminalIntentV1, + TerminalKind, + TraceRecorderV1, + build_terminal_artifact_body, +) + +pytestmark = pytest.mark.unit + + +def test_trace_is_deterministic_hash_chain_without_volatile_fields() -> None: + graph = GraphStateV1().promote_initial({"items": [{"sku": "A"}]}) + first = TraceRecorderV1().append("extract", "promoted", graph, {"source": "replay"}) + second = first.append("schema", "pass", graph, {}) + repeated = ( + TraceRecorderV1().append("extract", "promoted", graph, {"source": "replay"}).append("schema", "pass", graph, {}) + ) + + assert [event.sequence for event in second.events] == [0, 1] + assert second.trace_head == repeated.trace_head + assert second.events[1].previous_head == second.events[0].event_digest + + +def test_trace_rejects_credential_details() -> None: + with pytest.raises(ValueError, match="credential"): + TraceRecorderV1().append( + "extract", + "failed", + None, + {"api_key": "not-a-real-key"}, # pragma: allowlist secret + ) + + +def test_artifact_body_requires_intent_and_graph_consistency() -> None: + graph = GraphStateV1().promote_initial({"items": [{"sku": "A"}]}) + trace = TraceRecorderV1().append("terminal", "final", graph, {}) + intent = TerminalIntentV1( + kind=TerminalKind.FINAL, + reason_code="accepted", + graph_revision=graph.revision, + graph_digest=graph.graph_digest, + retryable=False, + ) + + artifact = build_terminal_artifact_body( + intent=intent, + graph=graph, + review={"disposition": "pass"}, + final_gate={"disposition": "pass"}, + trace_head=trace.trace_head, + fingerprints=FingerprintLayersV1( + runtime_contract_digest="sha256:runtime", + domain_semantic_digest="sha256:domain", + provider_execution_digest="sha256:provider", + input_digest="sha256:input", + host_plan_digest=None, + run_fingerprint="sha256:run", + ), + ) + assert artifact.graph == graph.graph + assert artifact.trace_head == trace.trace_head + + with pytest.raises(RuntimeInvariantError, match="digest"): + build_terminal_artifact_body( + intent=TerminalIntentV1( + kind=TerminalKind.FINAL, + reason_code="accepted", + graph_revision=graph.revision, + graph_digest="sha256:wrong", + retryable=False, + ), + graph=graph, + review=None, + final_gate=None, + trace_head=trace.trace_head, + fingerprints=FingerprintLayersV1( + runtime_contract_digest="sha256:runtime", + domain_semantic_digest="sha256:domain", + provider_execution_digest="sha256:provider", + input_digest="sha256:input", + host_plan_digest=None, + run_fingerprint="sha256:run", + ), + ) diff --git a/pyproject.toml b/pyproject.toml index 43ff9cbcc..7666b7049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dev = [ "pytest-cov~=5.0.0", "coverage[toml]==7.10.4", "pylint~=3.0.0", - "ruff>=0.11.0", + "ruff==0.15.18", "mypy>=1.16.1", "ty>=0.0.51", # pre-stable (0.0.x): consider tight pinning to avoid surprise breakage "pre-commit>=3.5.0",