From b4c112d60ad845a94a12e9f984764a5786d200e1 Mon Sep 17 00:00:00 2001 From: Alexander Belikov Date: Sun, 4 Oct 2026 20:47:17 +0200 Subject: [PATCH] repair json-ld; ttl default --- .env.example | 17 ++- .env.example.minimal | 6 +- CHANGELOG.md | 38 ++++- README.md | 17 ++- docs/concepts/index.md | 8 +- docs/concepts/ontologies_and_facts.md | 4 +- docs/concepts/structured_documents.md | 8 +- docs/guides/llm_caching.md | 6 +- docs/guides/performance.md | 40 +++-- docs/guides/telemetry.md | 3 +- docs/internals/telemetry_counters.md | 5 +- docs/internals/validation_layers.md | 3 +- ontocast/agent/complete_facts.py | 1 + ontocast/agent/criticise_facts.py | 1 + ontocast/agent/criticise_ontology.py | 4 +- ontocast/agent/render_facts.py | 23 ++- ontocast/agent/render_ontology.py | 8 +- ontocast/api/process_helpers.py | 1 + ontocast/config/settings.py | 21 ++- ontocast/integrations/langchain.py | 1 + ontocast/integrations/langgraph.py | 5 + ontocast/onto/enum.py | 26 +++- ontocast/onto/llm_graph_payload.py | 2 +- ontocast/onto/model.py | 3 + ontocast/onto/rdfgraph.py | 47 ++++-- ontocast/onto/run_manifest.py | 4 + ontocast/onto/state.py | 6 +- ontocast/onto/unit_states.py | 13 +- ontocast/prompt/facts_guidelines.py | 3 +- ontocast/prompt/graph_format.py | 58 +++++-- ontocast/stategraph/atomic.py | 38 ++++- ontocast/stategraph/node_factories.py | 3 + ontocast/stategraph/unit_pipeline.py | 2 + ontocast/tool/agg/aggregate.py | 34 ++--- ontocast/tool/agg/normalizer.py | 28 +++- ontocast/tool/chunk/prepare.py | 52 ++++++- ontocast/tool/facts_validation/__init__.py | 2 + .../tool/facts_validation/literal_repair.py | 75 +++++++++ pyproject.toml | 2 +- test/aggregation/test_collect_scaling.py | 72 +++++++++ test/aggregation/test_normalizer.py | 25 +++ test/chunking/test_prepare.py | 39 ++++- test/conftest.py | 7 +- test/facts/test_compact_iri_repair.py | 143 ++++++++++++++++++ test/facts/test_complete_facts.py | 2 + test/facts/test_context_computed_once.py | 2 + test/facts/test_critic_patch_pass.py | 31 ++++ test/facts/test_document_scoped_context.py | 2 + test/ontology/test_turtle_wire.py | 119 +++++++++++++++ test/test_atomic_loop_bounds.py | 1 + test/test_cli_server.py | 3 +- test/test_embedding_surface.py | 40 +++++ test/test_graph_format_profile.py | 13 ++ test/test_langchain_tools.py | 21 +++ test/test_llm_graph_format_default.py | 20 ++- test/test_max_visits_critic_propagation.py | 1 + test/test_output_layout.py | 119 +++++++++++++++ test/test_pipeline.py | 3 + test/test_rdfgraph_add.py | 64 ++++++++ test/test_unit_pipeline.py | 3 + uv.lock | 2 +- 61 files changed, 1215 insertions(+), 135 deletions(-) create mode 100644 test/aggregation/test_collect_scaling.py create mode 100644 test/facts/test_compact_iri_repair.py create mode 100644 test/ontology/test_turtle_wire.py create mode 100644 test/test_output_layout.py create mode 100644 test/test_rdfgraph_add.py diff --git a/.env.example b/.env.example index 66ab6609..914470ff 100644 --- a/.env.example +++ b/.env.example @@ -27,16 +27,23 @@ MAX_VISITS=1 # against the catalog you already have. Overridable per request. RENDER_MODE=ontology_and_facts # ontology | facts | ontology_and_facts -# Encoding the LLM uses for RDF graph payloads. `turtle` suits providers whose -# structured output handles strings more reliably than nested objects. -LLM_GRAPH_FORMAT=jsonld # jsonld | turtle +# Encoding the LLM uses for RDF graph payloads. `turtle` (recommended) spends +# fewer tokens per triple and cannot turn an IRI object into a string by +# accident; `jsonld` suits providers whose structured output handles long +# strings worse than nested objects. Watch llm/parse_retry when switching. +LLM_GRAPH_FORMAT=turtle # turtle | jsonld + +# Whitespace the LLM is asked to use in its responses. `compact` (recommended) +# asks for minified JSON and one-line-per-subject Turtle; `free` gives no +# instruction, and models typically indent their JSON. Indentation is billed as +# output tokens and carries nothing the parser reads. +LLM_OUTPUT_LAYOUT=compact # compact | free # Syntax of the `# ONTOLOGY` chapter in the facts render and critic prompts. # `auto` (default) takes the cheapest chapter the render mode can legally read: # `term_sheet` under RENDER_MODE=facts, `inherit` otherwise. `inherit` follows # LLM_GRAPH_FORMAT; `turtle` shows the ontology as Turtle whatever the wire -# format is, which spends fewer characters per triple than pretty-printed -# JSON-LD; `term_sheet` replaces the serialized graph with a line-per-term +# format is; `term_sheet` replaces the serialized graph with a line-per-term # listing -- name, surface forms, type, hierarchy, domain/range and usage # contract -- and is by a wide margin the cheapest of the three. # Context only: the graph payloads the model emits stay in LLM_GRAPH_FORMAT. diff --git a/.env.example.minimal b/.env.example.minimal index 1bb6e203..2a30c823 100644 --- a/.env.example.minimal +++ b/.env.example.minimal @@ -72,9 +72,9 @@ ONTOLOGY_CONTEXT_MODE=selected_single_ontology # it does not change the mode itself. #ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID= # IRI, ontology_id, or author prefix -# Encoding the LLM writes graphs in. `turtle` is worth trying if a provider's -# structured output handles strings better than nested objects. -LLM_GRAPH_FORMAT=jsonld # jsonld | turtle +# Encoding the LLM writes graphs in. Keep `turtle`; try `jsonld` if a +# provider's structured output handles long strings worse than nested objects. +LLM_GRAPH_FORMAT=turtle # turtle | jsonld # -- 4. Cost and quality -------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 5202c6bc..b68835ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.6.7] + +### Added + +- **`LLM_OUTPUT_LAYOUT`** (`compact` | `free`): `compact` asks for minified JSON responses and + one-line-per-subject Turtle; recorded in the run manifest's `prompting` block. +- **Counters** `rdf/turtle_repair` and `rdf/jsonld_rdflib_fallback` for graph payloads that + parsed only after recovery, and `repair/compact_iri_literal`. + +### Changed + +- **Default output encoding** is `LLM_GRAPH_FORMAT=turtle` with `LLM_OUTPUT_LAYOUT=compact` + (was `jsonld` with no layout instruction). Set `LLM_GRAPH_FORMAT=jsonld` and + `LLM_OUTPUT_LAYOUT=free` to keep the old behaviour; cached LLM responses are not reused. + +### Fixed + +- **JSON-LD instructions** no longer offer a plain string for an IRI object, which JSON-LD parses + as a literal; every JSON-LD instruction now requires `{"@id": …}`. +- **Compact IRIs written as plain strings** (`"unit:NanoM"`) on an IRI-valued position are coerced + to IRIs after a facts render and in critic and completion patches, which skipped every + literal check. +- **`ontocast_extract` and `make_ontocast_node`** honour `LLM_GRAPH_FORMAT`; both ran on the + class default. +- **Facts aggregation scales linearly with the document**: an entity's representation reads + only the triples that mention it, through the graph's indexes, instead of whole unit graphs + merged per mention. Long documents no longer stall before clustering; ontology anchor + selection and entity alignment no longer scan the whole graph per entity. +- **`RDFGraph +=`** adds in place, at the cost of the right-hand side (it rebuilt the whole graph). +- **`CHUNK_MIN_SIZE` held only within a labeled section**: neighbouring unlabeled sections are + now joined up to it, and a heading with no text of its own opens the unit that follows instead + of becoming a unit. Documents with sparse section labels yield far fewer, larger units; their + cached LLM responses are not reused. + +## [0.6.6] - 2026-10-04 ### Added @@ -33,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Removed `CONVERTER_PROFILE` values** `default` and `born_digital` dropped from shipped env examples. -## [0.6.5] +## [0.6.5] - 2026-10-04 ### Added diff --git a/README.md b/README.md index 1d27cca2..250f5ee0 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ -# OntoCast OntoCast logo +# OntoCast OntoCast logo **Ontology-guided extraction of RDF knowledge graphs from documents.** -![Python](https://img.shields.io/badge/python-3.12%2B-blue.svg) -[![PyPI version](https://badge.fury.io/py/ontocast.svg)](https://badge.fury.io/py/ontocast) -[![PyPI Downloads](https://static.pepy.tech/badge/ontocast)](https://pepy.tech/projects/ontocast) -[![Docs](https://img.shields.io/badge/docs-growgraph.github.io-224777.svg)](https://growgraph.github.io/ontocast/) -[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![pre-commit](https://github.com/growgraph/ontocast/actions/workflows/pre-commit.yml/badge.svg)](https://github.com/growgraph/ontocast/actions/workflows/pre-commit.yml) -[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.17796467.svg)](https://doi.org/10.5281/zenodo.17796467) +[![tests](https://img.shields.io/github/actions/workflow/status/growgraph/ontocast/tests.yml?branch=main&label=tests)](https://github.com/growgraph/ontocast/actions/workflows/tests.yml) +[![pre-commit](https://img.shields.io/github/actions/workflow/status/growgraph/ontocast/pre-commit.yml?branch=main&label=pre-commit)](https://github.com/growgraph/ontocast/actions/workflows/pre-commit.yml) +[![PyPI](https://img.shields.io/pypi/v/ontocast?color=224777)](https://pypi.org/project/ontocast/) +[![Python](https://img.shields.io/pypi/pyversions/ontocast?color=224777)](https://pypi.org/project/ontocast/) +[![Downloads](https://img.shields.io/pepy/dt/ontocast?color=224777)](https://pepy.tech/projects/ontocast) +[![Docs](https://img.shields.io/badge/docs-growgraph.github.io-224777)](https://growgraph.github.io/ontocast/) +[![License](https://img.shields.io/pypi/l/ontocast?color=224777)](https://github.com/growgraph/ontocast/blob/main/LICENSE) +[![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.17796467-224777)](https://doi.org/10.5281/zenodo.17796467) OntoCast reads documents and writes an RDF knowledge graph: an ontology that describes the domain, and the facts the documents state in its terms. Give it diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 01da9538..258d0808 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -42,9 +42,11 @@ A document is too long for one prompt, so OntoCast cuts it into parts (content units) of between [`CHUNK_MIN_SIZE`](../reference/configuration/chunking.md#chunk_min_size) and [`CHUNK_MAX_SIZE`](../reference/configuration/chunking.md#chunk_max_size) -characters. Cuts follow the document's sections, so no unit spans two of them, -and every unit carries the label of the section it came from, such as -`methods` or `results`. +characters. Cuts follow the document's sections, so no unit spans two labeled +sections, and every unit carries the label of the section it came from, such as +`methods` or `results`. Neighbouring sections that got no label are joined +until the unit reaches the minimum size, and a heading with no text of its own +opens the unit that follows it. Section labels decide what is extracted at all: reference lists are skipped by default, and you can ask for only some sections of a document. [Structured diff --git a/docs/concepts/ontologies_and_facts.md b/docs/concepts/ontologies_and_facts.md index c87ca808..0fd3f255 100644 --- a/docs/concepts/ontologies_and_facts.md +++ b/docs/concepts/ontologies_and_facts.md @@ -50,8 +50,8 @@ graph, deletions first. Two things follow: an explicit delete. The facts critic works the same way: its fixes are applied as a patch to the -unit's facts. The model writes patches as compact JSON-LD by default, or as -Turtle; see +unit's facts. The model writes patches as Turtle by default, or as compact +JSON-LD; see [`LLM_GRAPH_FORMAT`](../reference/configuration/pipeline.md#llm_graph_format). Either way, the patch that is applied is the same. diff --git a/docs/concepts/structured_documents.md b/docs/concepts/structured_documents.md index da889dc3..892b4c34 100644 --- a/docs/concepts/structured_documents.md +++ b/docs/concepts/structured_documents.md @@ -9,9 +9,11 @@ document it is reading, and how you choose the sections to extract. While chunking, OntoCast gives every content unit the label of the section it came from, such as `methods`, `results` or `risk_factors`. Units never span two -sections, so each has at most one label. A unit OntoCast cannot place confidently -gets no label at all: an unlabeled unit is merely never selected by a filter, -while a wrongly labeled one would be dropped or kept by mistake. +labeled sections, so each has at most one label. A unit OntoCast cannot place +confidently gets no label at all: an unlabeled unit is merely never selected by +a filter, while a wrongly labeled one would be dropped or kept by mistake. For +the same reason, size never joins unlabeled text to a labeled unit; neighbouring +unlabeled sections are joined with each other instead. Labels are recorded in the facts' [provenance](ontologies_and_facts.md#provenance), together with how each was decided and how confident that decision was. diff --git a/docs/guides/llm_caching.md b/docs/guides/llm_caching.md index 8b56a2b4..36849920 100644 --- a/docs/guides/llm_caching.md +++ b/docs/guides/llm_caching.md @@ -31,11 +31,11 @@ entries instead of misreading them. Because the whole prompt is part of the key, anything that changes the prompt misses the cache: a different document part, a changed ontology in the -catalog, a user instruction, `LLM_GRAPH_FORMAT`, `ONTOLOGY_CHAPTER_FORMAT`, or -an OntoCast upgrade that changes prompt wording. Settings that act only after +catalog, a user instruction, `LLM_GRAPH_FORMAT`, `LLM_OUTPUT_LAYOUT`, +`ONTOLOGY_CHAPTER_FORMAT`, or an OntoCast upgrade that changes prompt wording. Settings that act only after the last LLM call, such as entity disambiguation (`AGG_*`), leave the cache warm. -`LLM_PROMPT_CACHE_KEY` and `LLM_JSON_MODE` are not part of the key. +`LLM_PROMPT_CACHE_KEY` is not part of the key; `LLM_JSON_MODE` is, when it is on. Each entry is a JSON file holding the prompt, the response, the provider's response metadata and its token usage, so a hit reports the same usage as the diff --git a/docs/guides/performance.md b/docs/guides/performance.md index f4d78033..645f62aa 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -83,19 +83,37 @@ Every lever below changes the prompt text, so the first run after a change misses the [LLM response cache](llm_caching.md). [How prompts are built](../internals/prompt_construction.md) describes the mechanics. -### How much a triple costs +### How the model reads and writes the graph + +The defaults pair what the model reads with what it writes: + +| Run | Ontology the model reads | Graph the model writes | +|---|---|---| +| `RENDER_MODE=facts` | A term sheet ([below](#the-ontology-chapter-as-a-term-sheet)) | Turtle, compact layout | +| `ontology` or `ontology_and_facts` | Turtle: its chapter follows the output format, because the loop patches what it reads | Turtle, compact layout | [`LLM_GRAPH_FORMAT`](../reference/configuration/pipeline.md#llm_graph_format) -sets the syntax the model writes graphs in: `jsonld` (default) or `turtle`. -Turtle spends fewer characters per triple and changes nothing about what is -extracted. The two fail differently: a JSON-LD answer with mismatched brackets -is repaired or lost, while in Turtle an IRI the model builds from a phrase -containing a delimiter is invalid. Run both on your documents and compare -`llm/parse_retry` and `llm/parse_abandoned`. On a reasoning model, read -`reasoning_share_of_output` first: when most output tokens are reasoning, the -encoding matters less than the reasoning budget -([`LLM_REASONING_EFFORT`](../reference/configuration/llm.md#llm_reasoning_effort)). -The format can also be set per request. +sets the syntax the model writes graphs in. **Keep the default, `turtle`.** It +spends fewer tokens per triple than JSON-LD, and an IRI object cannot turn into +a string by accident: `unit:NanoM` is an IRI and `"unit:NanoM"` is visibly a +literal. In JSON-LD a bare `"unit:NanoM"` is a literal too, and nothing in the +syntax warns the model. Switch to `jsonld` for a provider whose structured +output handles long strings worse than nested objects: escaping or truncating +a long string shows up as `llm/parse_retry` and `rdf/turtle_repair`. Read those +counters on your own documents before and after switching. The format can also +be set per request. + +[`LLM_OUTPUT_LAYOUT`](../reference/configuration/pipeline.md#llm_output_layout) +sets the whitespace of the response. **Keep the default, `compact`.** It asks +for minified JSON and, under Turtle, one line per subject. Without it, models +indent their JSON, and every indentation token is billed output that the parser +discards. `free` exists to compare against; it changes no content, so compare +`output_tokens` with the layout on and off. + +On a reasoning model, read `reasoning_share_of_output` first: reasoning tokens +are output that no encoding touches, so when they dominate, the reasoning budget +([`LLM_REASONING_EFFORT`](../reference/configuration/llm.md#llm_reasoning_effort)) +matters more than either setting. ### The ontology chapter as a term sheet diff --git a/docs/guides/telemetry.md b/docs/guides/telemetry.md index caf27d1d..b96e80b8 100644 --- a/docs/guides/telemetry.md +++ b/docs/guides/telemetry.md @@ -132,7 +132,8 @@ their manifests instead of rerunning them. The HTTP routes return the same "render_mode": "ontology_and_facts", "loops": {"max_visits": 1, "facts_critic_passes": 1, "ontology_critic_passes": 0}, "llm": {"provider": "", "model_name": "", "temperature": 0.0, "max_inflight": 16}, - "prompting": {"llm_graph_format": "jsonld", "ontology_chapter_format": "inherit", + "prompting": {"llm_graph_format": "turtle", "llm_output_layout": "compact", + "ontology_chapter_format": "inherit", "ontology_context_scope": "unit", "fanout_warmup_units": 0, "parallel_workers": 16, "embedding_model_name": ""}, "budget": {"calls_count": 0, "input_tokens": 0, "output_tokens": 0, diff --git a/docs/internals/telemetry_counters.md b/docs/internals/telemetry_counters.md index 6d826692..972ae823 100644 --- a/docs/internals/telemetry_counters.md +++ b/docs/internals/telemetry_counters.md @@ -99,6 +99,9 @@ wall × effective workers ≈ llm/provider + llm/inflight_wait | `llm/parse_retry` | `ontocast/agent/common.py` | Calls re-sent because the previous answer did not parse or validate | | `llm/json_bracket_repair` | `ontocast/agent/common.py` | Answers recovered by correcting mismatched closing brackets | | `llm/parse_abandoned` | `ontocast/agent/common.py` | Calls given up after the retries ran out or the same JSON error recurred | +| `rdf/turtle_repair` | `ontocast/onto/rdfgraph.py` | Turtle payloads that parsed only after repair (truncated statement, unknown prefix, update-query wrapper) | +| `rdf/jsonld_rdflib_fallback` | `ontocast/onto/rdfgraph.py` | JSON-LD payloads that failed URDNA2015 normalization and were parsed by rdflib instead | +| `repair/compact_iri_literal` | `ontocast/tool/facts_validation/literal_repair.py` | Compact IRIs written as plain strings on an IRI-valued position and coerced back to IRIs, in renders and in critic and completion patches | | `llm/ontology_selection` | `ontocast/agent/select_ontology_catalog.py` | Ontology selection calls, one per unit in `selected_single_ontology` mode | | `chapter/text_chars_before` | `ontocast/agent/render_facts.py` | Summed length of capped text literals before the text caps | | `chapter/text_chars_after` | `ontocast/agent/render_facts.py` | The same after the caps | @@ -204,7 +207,7 @@ left out. | `ontocast_version`, `render_mode`, `current_domain`, `doc_iri`, `tenant`, `project` | What ran, and where its IRIs and graphs went | | `loops` | The effective `max_visits`, `facts_critic_passes`, `ontology_critic_passes` | | `llm` | Provider, model, temperature, the Ollama and reasoning settings, `requests_per_second`, `max_retries`, `prompt_cache_key`, `max_inflight` | -| `prompting` | `llm_graph_format`, the resolved `ontology_chapter_format`, `ontology_context_scope`, `fanout_warmup_units`, `parallel_workers`, `embedding_model_name`. Scope and warm-up are left out on the single-unit path | +| `prompting` | `llm_graph_format`, `llm_output_layout`, the resolved `ontology_chapter_format`, `ontology_context_scope`, `fanout_warmup_units`, `parallel_workers`, `embedding_model_name`. Scope and warm-up are left out on the single-unit path | | `budget` | The full budget, as above | | `critic`, `ontology_critic` | Per loop: `calls`, `accepted`, score minimum, median, maximum and histogram, fix severity histograms, `accept_reason_histogram`, `incumbent_accepted`, patch passes, fix outcomes (`fixes_applied`, `fixes_noop`, `fixes_rolled_back`, `fixes_junk_refused`, `fixes_unresolved_prefix`, `patches_rolled_back`), `units_unreviewed`, `units_skipped`, triples deleted and inserted | | `completion` | `calls`, `units`, `subjects_inserted`, `subjects_rolled_back`, `triples_inserted`, `measurements_recovered` | diff --git a/docs/internals/validation_layers.md b/docs/internals/validation_layers.md index 68d24a50..5074b85e 100644 --- a/docs/internals/validation_layers.md +++ b/docs/internals/validation_layers.md @@ -112,6 +112,7 @@ machine can decide without the model: |---|---| | Numeric retyping | An untyped number on a property with a numeric range gets that datatype | | `rdf:type` literal coercion | A type written as a string becomes the class IRI when it resolves unambiguously | +| Compact-IRI literal coercion | A plain string such as `"unit:NanoM"` on another predicate becomes the IRI, when its prefix is bound and either the schema says the predicate takes an IRI or the IRI is a catalog or fallback-vocabulary term. Critic and completion patches get the same repair before they are judged | | Near-miss predicate rewrite | A predicate not in the catalog is rewritten to the one catalog term whose name tokens contain, are contained in, or equal its own. `FACTS_PROPERTY_ALIAS_MIN_RATIO` only breaks ties among such candidates; string similarity alone never triggers a rewrite. A predicate the full catalog declares is never rewritten, even when the unit's snapshot lacks it | | Code resolution | A node carrying a code from `FACTS_CODE_PREDICATES` (such as `qudt:ucumCode "d"`) but no link to the coded individual gains that link, when exactly one catalog individual declares the code. The linking property comes from the schema's domain and range, or else from how the graph already links such nodes | | Degenerate bounds | Equal lower and upper bounds become one value, when the quantity fallback vocabulary names `numeric_value`, `lower_bound` and `upper_bound` | @@ -121,7 +122,7 @@ to the critic for repair: typed literals whose lexical form is invalid for the datatype, and string literals on a property whose range is a class (`FACTS_OBJECT_PROPERTY_LITERAL_CHECK`). -Type coercions, predicate rewrites and code resolutions are recorded as +Type and compact-IRI coercions, predicate rewrites and code resolutions are recorded as `GraphRepairRecord`s and returned per unit as `facts_repairs`, so a consumer can tell a machine rewrite from what the model asserted. Ambiguity is never resolved by guessing: two catalog terms claiming one code means no repair. diff --git a/ontocast/agent/complete_facts.py b/ontocast/agent/complete_facts.py index 18d8dd4d..7393b241 100644 --- a/ontocast/agent/complete_facts.py +++ b/ontocast/agent/complete_facts.py @@ -105,6 +105,7 @@ async def complete_facts( profile = get_graph_format_profile( state.llm_graph_format, ontology_chapter_format=state.ontology_chapter_format, + output_layout=state.llm_output_layout, ) parser = PydanticOutputParser(pydantic_object=FactsCompletionReport) diff --git a/ontocast/agent/criticise_facts.py b/ontocast/agent/criticise_facts.py index 51579855..38c0bb01 100644 --- a/ontocast/agent/criticise_facts.py +++ b/ontocast/agent/criticise_facts.py @@ -88,6 +88,7 @@ async def criticise_facts( profile = get_graph_format_profile( state.llm_graph_format, ontology_chapter_format=state.ontology_chapter_format, + output_layout=state.llm_output_layout, ) parser = PydanticOutputParser(pydantic_object=FactsCritiqueReport) diff --git a/ontocast/agent/criticise_ontology.py b/ontocast/agent/criticise_ontology.py index 0a0f9e85..db63d299 100644 --- a/ontocast/agent/criticise_ontology.py +++ b/ontocast/agent/criticise_ontology.py @@ -77,7 +77,9 @@ async def criticise_ontology( raise ValueError("Empty ontology context cannot be criticised") current_graph = access.effective_graph_for_prompt() - profile = get_graph_format_profile(state.llm_graph_format) + profile = get_graph_format_profile( + state.llm_graph_format, output_layout=state.llm_output_layout + ) parser = PydanticOutputParser(pydantic_object=OntologyCritiqueReport) llm_tool: LLMTool = await tools.get_llm_tool(state.budget_tracker) diff --git a/ontocast/agent/render_facts.py b/ontocast/agent/render_facts.py index 37fc4746..e821d0a2 100644 --- a/ontocast/agent/render_facts.py +++ b/ontocast/agent/render_facts.py @@ -49,6 +49,7 @@ expand_vocabulary_terms, normalize_literals_against_schema, promote_degenerate_bounds_from_vocabulary, + repair_compact_iri_literals, repair_literal_type_objects, repair_property_aliases, resolve_code_literals, @@ -95,14 +96,19 @@ def _normalize_and_repair_graph( started = time.perf_counter() retyped = normalize_literals_against_schema(graph, ontology_context_graph) type_repaired, _type_findings, type_records = repair_literal_type_objects(graph) + vocabulary_terms = expand_vocabulary_terms( + tools.quantity_fallback_vocabulary, graph, ontology_context_graph + ) + catalog_terms = tools.catalog_terms() + iri_repaired, iri_records = repair_compact_iri_literals( + graph, ontology_context_graph, catalog_terms | vocabulary_terms + ) rewritten, _alias_findings, alias_records = repair_property_aliases( graph, ontology_context_graph, min_ratio=tools.property_alias_min_ratio, - exempt_terms=expand_vocabulary_terms( - tools.quantity_fallback_vocabulary, graph, ontology_context_graph - ), - full_catalog_terms=tools.catalog_terms(), + exempt_terms=vocabulary_terms, + full_catalog_terms=catalog_terms, ) resolved, code_records = resolve_code_literals( graph, ontology_context_graph, tools.code_predicates @@ -114,16 +120,18 @@ def _normalize_and_repair_graph( budget_tracker.add_duration( "repair/deterministic", time.perf_counter() - started ) - if retyped or rewritten or type_repaired or resolved: + if retyped or rewritten or type_repaired or iri_repaired or resolved: logger.info( "Deterministic graph repair: retyped %d literal(s), coerced %d " - "rdf:type literal(s), rewrote %d alias triple(s), resolved %d code(s)", + "rdf:type literal(s), coerced %d compact-IRI literal(s), rewrote %d " + "alias triple(s), resolved %d code(s)", retyped, type_repaired, + iri_repaired, rewritten, resolved, ) - return graph, [*type_records, *alias_records, *code_records] + return graph, [*type_records, *iri_records, *alias_records, *code_records] async def render_facts( @@ -336,6 +344,7 @@ async def render_facts_fresh( profile = get_graph_format_profile( state.llm_graph_format, ontology_chapter_format=state.ontology_chapter_format, + output_layout=state.llm_output_layout, ) parser = PydanticOutputParser(pydantic_object=FactsRenderReport) diff --git a/ontocast/agent/render_ontology.py b/ontocast/agent/render_ontology.py index e06b667b..9e998203 100644 --- a/ontocast/agent/render_ontology.py +++ b/ontocast/agent/render_ontology.py @@ -145,7 +145,9 @@ async def render_ontology_fresh( ) -> UnitOntologyState: """Create a brand-new catalog ontology from text (empty seed path).""" - profile = get_graph_format_profile(state.llm_graph_format) + profile = get_graph_format_profile( + state.llm_graph_format, output_layout=state.llm_output_layout + ) parser = PydanticOutputParser(pydantic_object=OntologyRenderReport) logger.info("Rendering fresh ontology") intro_instruction = intro_instruction_fresh.format( @@ -248,7 +250,9 @@ async def render_ontology_update( """Complement an existing snapshot via GraphUpdate inserts.""" state.quarantined_literal_triples = [] - profile = get_graph_format_profile(state.llm_graph_format) + profile = get_graph_format_profile( + state.llm_graph_format, output_layout=state.llm_output_layout + ) parser = PydanticOutputParser(pydantic_object=GraphUpdateRenderReport) access = ontology_access_for_unit_ontology(state) intro_instruction = _build_update_intro(state, access) diff --git a/ontocast/api/process_helpers.py b/ontocast/api/process_helpers.py index 59b48756..9ec4b7d6 100644 --- a/ontocast/api/process_helpers.py +++ b/ontocast/api/process_helpers.py @@ -346,6 +346,7 @@ def dump_run_manifest( ), prompting=RunManifestPrompting( llm_graph_format=str(config.server.llm_graph_format), + llm_output_layout=str(config.server.llm_output_layout), ontology_chapter_format=str(config.server.ontology_chapter_format), # Null on the single-unit path rather than echoed back: both # settings are read only by the document fan-out node, so recording diff --git a/ontocast/config/settings.py b/ontocast/config/settings.py index 409420d0..262b152d 100644 --- a/ontocast/config/settings.py +++ b/ontocast/config/settings.py @@ -22,6 +22,7 @@ ) from ontocast.onto.enum import ( LLMGraphFormat, + LLMOutputLayout, OntologyChapterFormat, OntologyContextMode, OntologyContextScope, @@ -990,10 +991,24 @@ class ServerConfig(BaseSettings): description="Rendering mode: ontology, facts, or ontology_and_facts.", ) llm_graph_format: LLMGraphFormat = Field( - default=LLMGraphFormat.JSONLD, + default=LLMGraphFormat.TURTLE, description=( - "Format the LLM writes RDF graphs in: 'jsonld' (compact JSON-LD " - "objects) or 'turtle' (Turtle strings)." + "Format the LLM writes RDF graphs in: 'turtle' (Turtle strings) or " + "'jsonld' (compact JSON-LD objects). Turtle spends fewer tokens per " + "triple and cannot write an IRI object as a string by accident; " + "'jsonld' suits providers whose structured output handles long " + "strings worse than nested objects." + ), + ) + llm_output_layout: LLMOutputLayout = Field( + default=LLMOutputLayout.COMPACT, + description=( + "Whitespace the LLM is asked to use in its structured responses: " + "'compact' asks for minified JSON and one-line-per-subject Turtle " + "without indentation; 'free' gives no instruction (models " + "typically indent JSON). Indentation is billed as output tokens " + "and carries nothing the parser reads. Applies to every call that " + "emits a graph payload." ), ) ontology_chapter_format: OntologyChapterFormat = Field( diff --git a/ontocast/integrations/langchain.py b/ontocast/integrations/langchain.py index 69d3f7fc..ce4c338a 100644 --- a/ontocast/integrations/langchain.py +++ b/ontocast/integrations/langchain.py @@ -497,6 +497,7 @@ async def run( ), ontology_user_instruction=instruction, facts_user_instruction=instruction, + llm_graph_format=tools.config.server.llm_graph_format, **({"current_domain": domain} if domain else {}), ) onto_result, facts_result = await run_unit_pipeline(state, tools) diff --git a/ontocast/integrations/langgraph.py b/ontocast/integrations/langgraph.py index 1822b300..eacf470f 100644 --- a/ontocast/integrations/langgraph.py +++ b/ontocast/integrations/langgraph.py @@ -68,6 +68,11 @@ def make_ontocast_node( async def ontocast_node(state: Any, config: RunnableConfig) -> dict[str, Any]: initial = to_agent_state(state) + # The format is read off the state, not the toolbox, so a mapping that + # leaves it unset would run on the class default instead of the + # deployment's LLM_GRAPH_FORMAT. + if "llm_graph_format" not in initial.model_fields_set: + initial.llm_graph_format = tools.config.server.llm_graph_format # Merge rather than replace: the caller's config carries callbacks, # tags and run metadata that tracing depends on. merged: RunnableConfig = {**(config or {})} diff --git a/ontocast/onto/enum.py b/ontocast/onto/enum.py index cfe5269c..30f1c1c1 100644 --- a/ontocast/onto/enum.py +++ b/ontocast/onto/enum.py @@ -137,17 +137,33 @@ class RenderMode(StrEnum): class LLMGraphFormat(StrEnum): """Format used by the LLM when emitting RDF graph payloads. - - ``jsonld`` (default): graph fields are compact JSON-LD objects embedded - directly in the structured LLM response. Internally parsed back into - ``RDFGraph``. - - ``turtle``: graph fields are Turtle strings (legacy encoding, kept for - providers whose structured output handles strings better than objects). + - ``turtle`` (default): graph fields are Turtle strings. Fewer tokens per + triple than JSON-LD, and an IRI object cannot be written as a string by + accident: ``unit:NanoM`` is an IRI, ``"unit:NanoM"`` visibly a literal. + - ``jsonld``: graph fields are compact JSON-LD objects embedded directly in + the structured LLM response, for providers whose structured output + handles long strings worse than nested objects. Internally parsed back + into ``RDFGraph``. """ TURTLE = "turtle" JSONLD = "jsonld" +class LLMOutputLayout(StrEnum): + """Whitespace the LLM is asked to use in its structured responses. + + - ``compact`` (default): the JSON response is minified, and a Turtle graph + string keeps each subject on one line without indentation. Indentation + is billed as output tokens and carries nothing the parser reads. + - ``free``: no layout instruction; the model chooses, and typically + pretty-prints JSON with indentation. + """ + + FREE = "free" + COMPACT = "compact" + + class OntologyChapterFormat(StrEnum): """Syntax of the ``# ONTOLOGY`` chapter in the facts prompts. diff --git a/ontocast/onto/llm_graph_payload.py b/ontocast/onto/llm_graph_payload.py index 47771634..ee4b9591 100644 --- a/ontocast/onto/llm_graph_payload.py +++ b/ontocast/onto/llm_graph_payload.py @@ -11,7 +11,7 @@ from ontocast.onto.rdfgraph import RDFGraph llm_graph_format_ctx: ContextVar[LLMGraphFormat] = ContextVar( - "llm_graph_format", default=LLMGraphFormat.JSONLD + "llm_graph_format", default=LLMGraphFormat.TURTLE ) diff --git a/ontocast/onto/model.py b/ontocast/onto/model.py index 6e38b31a..3fe5690f 100644 --- a/ontocast/onto/model.py +++ b/ontocast/onto/model.py @@ -641,6 +641,9 @@ class FactsUnitFindingKind(StrEnum): PROPERTY_ALIAS = "property_alias" CLOSED_RANGE_LITERAL = "closed_range_literal" LITERAL_TYPE_OBJECT = "literal_type_object" + #: A compact IRI written as a plain string on an IRI-valued position, + #: coerced back to the IRI. Repair-only: never raised as a finding. + COMPACT_IRI_LITERAL = "compact_iri_literal" NUMERIC_COVERAGE = "numeric_coverage" LABEL_ONLY_NUMBER = "label_only_number" SCALAR_AS_BOUNDS = "scalar_as_bounds" diff --git a/ontocast/onto/rdfgraph.py b/ontocast/onto/rdfgraph.py index 9af6cc37..fca93c92 100644 --- a/ontocast/onto/rdfgraph.py +++ b/ontocast/onto/rdfgraph.py @@ -690,6 +690,18 @@ def _triple_sort_key(triple: tuple) -> tuple[tuple[str, str, str], ...]: return tuple(_term_sort_key(term) for term in triple) +def _record_parse_event(name: str) -> None: + """Count a recovered parse on the running task's budget tracker, if any. + + Recovery is otherwise only a log line, so a run could not tell a payload + that parsed cleanly from one that was repaired. + """ + # Deferred: the tool layer imports this module. + from ontocast.tool.llm import record_active_count + + record_active_count(name) + + class RDFGraph(Graph): """Subclass of rdflib.Graph with Pydantic schema support. @@ -761,29 +773,30 @@ def __add__(self, other: Union["RDFGraph", Graph, Iterable]) -> "RDFGraph": return result def __iadd__(self, other: Union["RDFGraph", Graph, Iterable]) -> "RDFGraph": - """In-place addition operator for RDFGraph instances. + """In-place addition: add ``other``'s triples and prefixes to this graph. - Merges the RDF graphs while maintaining the RDFGraph type and binding prefixes. + Costs the size of ``other``, not of this graph. Prefixes combine as in + :meth:`__add__`: existing bindings win and a clashing incoming prefix + is renamed. Args: - other: The graph to add to this one. + other: The graph (or iterable of triples) to add. Returns: RDFGraph: self after modification. """ - # Use __add__ to get the merged result with proper prefix binding - result = self.__add__(other) - - # Clear current graph and copy the result - self.remove((None, None, None)) # Remove all triples - - # Copy all triples from result - copy_triples(result, self, origin="RDFGraph.__iadd__") - - # Copy namespace bindings from result - for prefix, uri in result.namespaces(): - self.bind(prefix, uri) - + if other is self: + return self + existing = {prefix: str(uri) for prefix, uri in self.namespaces() if prefix} + incoming: dict[str, str] = {} + if isinstance(other, Graph): + incoming = { + prefix: str(uri) for prefix, uri in other.namespaces() if prefix + } + copy_triples(other, self, origin="RDFGraph.__iadd__") + for prefix, uri in merge_namespace_bindings(existing, incoming).items(): + if existing.get(prefix) != uri: + self.bind(prefix, uri) return self def copy(self) -> "RDFGraph": @@ -973,6 +986,7 @@ def _from_turtle_str(cls, turtle_str: str) -> "RDFGraph": repaired_graph = cls() repaired_graph.parse(data=repaired_turtle, format="turtle") repaired_graph._sanitize_prefix_boundaries_from_turtle(normalized_turtle) + _record_parse_event("rdf/turtle_repair") return repaired_graph def _sanitize_prefix_boundaries_from_turtle(self, turtle_str: str) -> None: @@ -1460,6 +1474,7 @@ def _from_jsonld_str(cls, jsonld_str: str) -> "RDFGraph": g = cls() g.parse(data=json.dumps(jsonld_data), format="json-ld") cls._bind_context_prefixes(g, jsonld_data) + _record_parse_event("rdf/jsonld_rdflib_fallback") return g normalized_str = normalized if isinstance(normalized, str) else str(normalized) diff --git a/ontocast/onto/run_manifest.py b/ontocast/onto/run_manifest.py index 3ee6e469..536adcff 100644 --- a/ontocast/onto/run_manifest.py +++ b/ontocast/onto/run_manifest.py @@ -470,6 +470,10 @@ class RunManifestPrompting(BaseModel): default=None, description="Wire format the model emitted graphs in.", ) + llm_output_layout: str | None = Field( + default=None, + description="Whitespace the model was asked to use in its responses.", + ) ontology_chapter_format: str | None = Field( default=None, description=( diff --git a/ontocast/onto/state.py b/ontocast/onto/state.py index 0ad54e74..a21d2e3b 100644 --- a/ontocast/onto/state.py +++ b/ontocast/onto/state.py @@ -771,11 +771,11 @@ class AgentState(BasePydanticModel): description=("Rendering mode: ontology, facts, or ontology_and_facts."), ) llm_graph_format: LLMGraphFormat = Field( - default=LLMGraphFormat.JSONLD, + default=LLMGraphFormat.TURTLE, description=( "Format used by the LLM for emitting RDF graph payloads: " - "'jsonld' (default; compact JSON-LD objects embedded directly in the " - "structured response) or 'turtle' (legacy Turtle strings)." + "'turtle' (default; Turtle strings) or 'jsonld' (compact JSON-LD " + "objects embedded directly in the structured response)." ), ) ontology_context_mode: OntologyContextMode = Field( diff --git a/ontocast/onto/unit_states.py b/ontocast/onto/unit_states.py index 678876ac..bdecf9ba 100644 --- a/ontocast/onto/unit_states.py +++ b/ontocast/onto/unit_states.py @@ -11,6 +11,7 @@ from ontocast.onto.enum import ( FailureStage, LLMGraphFormat, + LLMOutputLayout, OntologyAssemblyMode, OntologyChapterFormat, Status, @@ -92,10 +93,18 @@ class UnitState(BasePydanticModel): #: budget rather than per-unit state. max_visits_per_node: int = Field(default=1, ge=1) llm_graph_format: LLMGraphFormat = Field( - default=LLMGraphFormat.JSONLD, + default=LLMGraphFormat.TURTLE, description=( "Format used by the LLM for emitting RDF graph payloads: " - "'jsonld' (default) or 'turtle' (legacy)." + "'turtle' (default) or 'jsonld'." + ), + ) + llm_output_layout: LLMOutputLayout = Field( + default=LLMOutputLayout.COMPACT, + description=( + "Whitespace the LLM is asked to use in its structured responses. " + "Threaded from ServerConfig like ontology_context_max_triples; " + "read by both loops." ), ) ontology_context_max_triples: int | None = Field( diff --git a/ontocast/prompt/facts_guidelines.py b/ontocast/prompt/facts_guidelines.py index 595f1f7f..5eb4843a 100644 --- a/ontocast/prompt/facts_guidelines.py +++ b/ontocast/prompt/facts_guidelines.py @@ -143,7 +143,8 @@ facts_literal_rules_jsonld = """\ - Dates: {"@value": "2024-01-15", "@type": "xsd:date"} — never use Turtle ^^ syntax - Numbers: {"@value": "42", "@type": "xsd:integer"} etc. - - Language tags: {"@value": "...", "@language": "en"}""" + - Language tags: {"@value": "...", "@language": "en"} + - IRI objects: {"@id": "unit:NanoM"} — a bare string ("unit:NanoM") is a literal, not an IRI""" facts_output_hygiene_turtle = ( "No comments in Turtle: output must contain only @prefix declarations and triples " diff --git a/ontocast/prompt/graph_format.py b/ontocast/prompt/graph_format.py index 57827651..03222c17 100644 --- a/ontocast/prompt/graph_format.py +++ b/ontocast/prompt/graph_format.py @@ -11,7 +11,7 @@ from langchain_core.output_parsers import PydanticOutputParser from pydantic import BaseModel -from ontocast.onto.enum import LLMGraphFormat, OntologyChapterFormat +from ontocast.onto.enum import LLMGraphFormat, LLMOutputLayout, OntologyChapterFormat from ontocast.onto.llm_graph_payload import llm_graph_format_ctx from ontocast.onto.ontology_condense import ( CondenseReport, @@ -76,7 +76,8 @@ class IndexedChapter: 4. Typed literals MUST use the value/type form: {"@value": "2024-01-15", "@type": "xsd:date"}. Language-tagged literals use {"@value": "...", "@language": "en"}. 5. Multi-valued predicates use a JSON array of objects/values. -6. Object references use {"@id": "prefix:local"} (or a plain compact IRI string when unambiguous). +6. Object references ALWAYS use {"@id": "prefix:local"}. A bare string ("prefix:local") + is a literal, not an IRI: the triple loses its link. 7. No comments, no trailing prose - output strictly valid JSON. 8. Never use Turtle syntax (no ^^, no @prefix) inside JSON values. """ @@ -131,8 +132,9 @@ class IndexedChapter: 3. Use compact IRIs (`prefix:local`) throughout - never expand to full URIs in the body. 4. Typed literals MUST use the value/type form: {"@value": "...", "@type": "xsd:date"}. Language-tagged literals use {"@value": "...", "@language": "en"}. -5. No comments, no trailing prose - output strictly valid JSON. -6. NEVER use UPDATE query syntax or Turtle ^^/@prefix inside JSON values. +5. Object references ALWAYS use {"@id": "prefix:local"}. A bare string is a literal. +6. No comments, no trailing prose - output strictly valid JSON. +7. NEVER use UPDATE query syntax or Turtle ^^/@prefix inside JSON values. Shape of the whole response — `insert_graph` sits at the top level, and its object is closed with `}` before the response's final `}`: @@ -192,7 +194,27 @@ class IndexedChapter: (inline `@context` or compact IRIs only): Example: "{\\"@context\\": {\\"schema\\": \\"https://schema.org/\\"}, \\"@id\\": \\"cd:alice\\", \\"schema:worksFor\\": {\\"@id\\": \\"cd:acme\\"}}" Use `{"@value": "...", "@type": "xsd:date"}` for typed literals and `{"@value": "...", "@language": "en"}` -for language-tagged literals. Never use Turtle ^^ syntax inside these JSON strings. +for language-tagged literals, and `{"@id": "prefix:local"}` for every IRI object -- a bare +string is a literal. Never use Turtle ^^ syntax inside these JSON strings. +""" +) + + +# Appended to the response format instructions, so it reaches every call that +# emits a graph payload. The examples above stay indented for the reader; the +# clause says so, or a model copies their layout. +_LAYOUT_INSTRUCTION_COMPACT_JSON = """ + +# RESPONSE LAYOUT + +Write the whole JSON response minified: no line breaks and no indentation +between JSON tokens. Examples in this prompt are indented for reading only. +""" + +_LAYOUT_INSTRUCTION_COMPACT_TURTLE = ( + _LAYOUT_INSTRUCTION_COMPACT_JSON + + """Inside a Turtle string, write each subject and all its predicate-object pairs +on one line (`s p1 o1 ; p2 o2 .`), with no indentation. """ ) @@ -212,6 +234,9 @@ class GraphFormatProfile: #: context from the wire the model writes on. Nothing else on the profile #: -- output instructions, the facts chapter, parsing -- reads it. ontology_chapter_format: OntologyChapterFormat = OntologyChapterFormat.INHERIT + #: Whitespace the response is asked to use. Read only by + #: :meth:`format_instructions`; parsing is layout-blind. + output_layout: LLMOutputLayout = LLMOutputLayout.FREE @property def renders_term_sheet(self) -> bool: @@ -467,11 +492,19 @@ def format_instructions( *, web_search_enabled: bool = True, ) -> str: - return format_instructions_for_model( + instructions = format_instructions_for_model( report_cls, self.format, web_search_enabled=web_search_enabled, ) + return instructions + self._layout_instruction() + + def _layout_instruction(self) -> str: + if self.output_layout == LLMOutputLayout.FREE: + return "" + if self.format == LLMGraphFormat.TURTLE: + return _LAYOUT_INSTRUCTION_COMPACT_TURTLE + return _LAYOUT_INSTRUCTION_COMPACT_JSON def parse_report(self, report_cls: type[T], text: str) -> T: token = llm_graph_format_ctx.set(self.format) @@ -504,11 +537,16 @@ def __exit__(self, *exc: object) -> None: #: render mode before anything reaches here. A profile for it would silently #: serve whichever chapter its fallback happened to be, on a run whose manifest #: recorded the unresolved name. -_PROFILES: dict[tuple[LLMGraphFormat, OntologyChapterFormat], GraphFormatProfile] = { - (fmt, chapter): GraphFormatProfile(format=fmt, ontology_chapter_format=chapter) +_PROFILES: dict[ + tuple[LLMGraphFormat, OntologyChapterFormat, LLMOutputLayout], GraphFormatProfile +] = { + (fmt, chapter, layout): GraphFormatProfile( + format=fmt, ontology_chapter_format=chapter, output_layout=layout + ) for fmt in LLMGraphFormat for chapter in OntologyChapterFormat if chapter is not OntologyChapterFormat.AUTO + for layout in LLMOutputLayout } @@ -516,6 +554,7 @@ def get_graph_format_profile( fmt: LLMGraphFormat, *, ontology_chapter_format: OntologyChapterFormat = OntologyChapterFormat.INHERIT, + output_layout: LLMOutputLayout = LLMOutputLayout.FREE, ) -> GraphFormatProfile: """The profile for a wire format. @@ -526,6 +565,7 @@ def get_graph_format_profile( deployment's ``ONTOLOGY_CHAPTER_FORMAT``; callers that leave it at ``INHERIT`` get a chapter in ``fmt``. ``AUTO`` is rejected -- it is resolved against the render mode when the configuration is built. + output_layout: Whitespace the response is asked to use. Raises: ValueError: If ``ontology_chapter_format`` is ``AUTO``. @@ -536,4 +576,4 @@ def get_graph_format_profile( "when the configuration is built and must not reach a prompt " "profile; pass the resolved value." ) - return _PROFILES[(fmt, ontology_chapter_format)] + return _PROFILES[(fmt, ontology_chapter_format, output_layout)] diff --git a/ontocast/stategraph/atomic.py b/ontocast/stategraph/atomic.py index dcf69ca4..bd7f818a 100644 --- a/ontocast/stategraph/atomic.py +++ b/ontocast/stategraph/atomic.py @@ -43,7 +43,10 @@ Suggestions, ) from ontocast.onto.ontology import Ontology -from ontocast.onto.ontology_access import build_llm_prefix_map +from ontocast.onto.ontology_access import ( + build_llm_prefix_map, + ontology_access_for_unit_facts, +) from ontocast.onto.rdfgraph import RDFGraph from ontocast.onto.retrieval_capabilities import OntologyContextConfigError from ontocast.onto.triple_index import Triple @@ -58,7 +61,9 @@ FactsAcceptancePolicy, ValidationPolicy, collect_unit_findings, + expand_vocabulary_terms, material_defects, + repair_compact_iri_literals, unit_numeric_inventory, ) from ontocast.tool.facts_validation.critic_patch import ( @@ -134,6 +139,30 @@ def _resolve_max_visits_limit(state_visits: int, override: int | None) -> int: return max(1, visits) +def _repair_facts_patch( + unit_state: UnitFactsState, atomic: AtomicToolBox +) -> list[GraphRepairRecord]: + """Repairs a critic or completion patch needs before it is judged. + + Patches are parsed outside the render, so they miss its parse-time repairs; + a bare-string IRI they insert would otherwise read as a missing property. + """ + graph = unit_state.content_unit.graph + context = ontology_access_for_unit_facts(unit_state).effective_ontology_for_prompt() + known = atomic.catalog_terms() | expand_vocabulary_terms( + atomic.quantity_fallback_vocabulary, graph, context.graph + ) + _, records = repair_compact_iri_literals(graph, context.graph, known) + return records + + +def _repair_ontology_patch( + unit_state: UnitOntologyState, atomic: AtomicToolBox +) -> list[GraphRepairRecord]: + """The ontology loop applies patches as updates; nothing to repair here.""" + return [] + + def _collect_facts_findings( unit_state: UnitFactsState, atomic: AtomicToolBox | None = None, @@ -452,6 +481,7 @@ def _apply_patches( ) ) continue + repairs = phase.repair_patch(unit_state, atomic) findings = _timed_findings(unit_state, atomic, phase) mandatory_after = sum(1 for finding in findings if finding.mandatory) reason = _regression_reason( @@ -490,6 +520,7 @@ def _apply_patches( ) continue run.applied.append(patch) + unit_state.applied_repairs.extend(repairs) run.deleted += patch.deletes run.inserted += patch.inserts baseline = mandatory_after @@ -900,6 +931,9 @@ class LoopPhase: render: Callable[..., Awaitable[None]] criticise: Callable[..., Awaitable[None]] collect_findings: Callable[..., list] + #: Deterministic repairs on a patched graph, run before it is judged. Its + #: records are kept only if the patch is. + repair_patch: Callable[..., list[GraphRepairRecord]] critic_passes: Callable[[AtomicToolBox], int] patch_policy: Callable[[AtomicToolBox], CriticPatchPolicy] acceptance_policy: Callable[[AtomicToolBox], FactsAcceptancePolicy] @@ -918,6 +952,7 @@ class LoopPhase: render=_render_facts_phase, criticise=_criticise_facts_phase, collect_findings=_collect_facts_findings, + repair_patch=_repair_facts_patch, critic_passes=lambda atomic: atomic.facts_critic_passes, patch_policy=lambda atomic: atomic.facts_patch_policy, acceptance_policy=lambda atomic: atomic.acceptance_policy, @@ -934,6 +969,7 @@ class LoopPhase: render=_render_ontology_phase, criticise=_criticise_ontology_phase, collect_findings=_collect_ontology_findings, + repair_patch=_repair_ontology_patch, critic_passes=lambda atomic: atomic.ontology_critic_passes, patch_policy=lambda atomic: atomic.ontology_patch_policy, acceptance_policy=lambda atomic: atomic.ontology_acceptance_policy, diff --git a/ontocast/stategraph/node_factories.py b/ontocast/stategraph/node_factories.py index 53f6da04..afb718bd 100644 --- a/ontocast/stategraph/node_factories.py +++ b/ontocast/stategraph/node_factories.py @@ -216,6 +216,7 @@ async def process_unit( current_domain=state.current_domain, ontology_max_triples=tools.config.server.ontology_max_triples, llm_graph_format=state.llm_graph_format, + llm_output_layout=tools.config.server.llm_output_layout, ontology_context_max_triples=tools.config.server.ontology_context_max_triples, ) loop_start = time.perf_counter() @@ -575,6 +576,7 @@ async def consolidate_ontology(state: AgentState) -> AgentState: current_domain=state.current_domain, ontology_max_triples=tools.config.server.ontology_max_triples, llm_graph_format=state.llm_graph_format, + llm_output_layout=tools.config.server.llm_output_layout, ontology_context_max_triples=tools.config.server.ontology_context_max_triples, working_graph=snap.graph.copy(), assembly_anchor_iri=primary.iri or "", @@ -699,6 +701,7 @@ async def process_unit( budget_tracker=unit_budget, max_visits_per_node=state.max_visits, llm_graph_format=state.llm_graph_format, + llm_output_layout=tools.config.server.llm_output_layout, ontology_context_max_triples=tools.config.server.ontology_context_max_triples, ontology_chapter_format=tools.config.server.ontology_chapter_format, ontology_text_caps=tools.config.server.ontology_text_caps, diff --git a/ontocast/stategraph/unit_pipeline.py b/ontocast/stategraph/unit_pipeline.py index 664a0cb7..f2f3c71b 100644 --- a/ontocast/stategraph/unit_pipeline.py +++ b/ontocast/stategraph/unit_pipeline.py @@ -145,6 +145,7 @@ async def run_unit_pipeline( current_domain=agent_state.current_domain, ontology_max_triples=tools.config.server.ontology_max_triples, llm_graph_format=agent_state.llm_graph_format, + llm_output_layout=tools.config.server.llm_output_layout, ontology_context_max_triples=tools.config.server.ontology_context_max_triples, ) logger.info("run_unit_pipeline: starting ontology loop") @@ -182,6 +183,7 @@ async def run_unit_pipeline( budget_tracker=deepcopy(agent_state.budget_tracker), max_visits_per_node=max_visits, llm_graph_format=agent_state.llm_graph_format, + llm_output_layout=tools.config.server.llm_output_layout, ontology_context_max_triples=tools.config.server.ontology_context_max_triples, ontology_chapter_format=tools.config.server.ontology_chapter_format, ontology_text_caps=tools.config.server.ontology_text_caps, diff --git a/ontocast/tool/agg/aggregate.py b/ontocast/tool/agg/aggregate.py index bb2cc8e8..8bfe2343 100644 --- a/ontocast/tool/agg/aggregate.py +++ b/ontocast/tool/agg/aggregate.py @@ -573,7 +573,8 @@ class _EntityCollectionState(BaseModel): known_entities: set[URIRef] entities: set[URIRef] = Field(default_factory=set) source_entities: set[URIRef] = Field(default_factory=set) - entity_graphs: dict[URIRef, RDFGraph] = Field(default_factory=dict) + # Every unit triple, once: the shared context of all collected entities. + context: RDFGraph = Field(default_factory=RDFGraph) entity_doc_iris: dict[URIRef, URIRef] = Field(default_factory=dict) entity_classification: dict[URIRef, EntityClassification] = Field( default_factory=dict @@ -1462,11 +1463,6 @@ def _classification_priority(classification: EntityClassification) -> int: return 2 return 1 - @staticmethod - def _merge_into_context_graph(target: RDFGraph, source: RDFGraph) -> None: - """Merge source triples/namespaces into a per-entity context graph.""" - target += source - def _register_entity( self, *, @@ -1474,13 +1470,9 @@ def _register_entity( unit: ContentUnit, state: _EntityCollectionState, ) -> None: - """Register one URI entity with merged context and stable classification.""" + """Register one URI entity with its document and stable classification.""" state.entities.add(entity) state.source_entities.add(entity) - if entity not in state.entity_graphs: - state.entity_graphs[entity] = unit.graph.copy() - else: - self._merge_into_context_graph(state.entity_graphs[entity], unit.graph) state.entity_doc_iris.setdefault(entity, unit.doc_iri) current = state.entity_classification.get(entity, EntityClassification.FACT) candidate = self._classify_entity_for_unit(entity, unit, state.known_entities) @@ -1517,21 +1509,25 @@ def _collect_all_entities( ]: """Collect all entities from all content unit graphs. - Each entity is associated with the graph it was found in and the - ``doc_iri`` of the :class:`ContentUnit` that produced it. When an - entity appears in several units the *last-seen* ``doc_iri`` wins (in + Each entity is associated with a graph holding its triples and the + ``doc_iri`` of the first :class:`ContentUnit` that mentions it (in practice most pipelines aggregate chunks of the same document, so all ``doc_iri`` values are identical). Args: units: List of content units to aggregate. + known_ontology_entities: Entities of the selected ontology, used + for classification. Returns: Tuple of ( entities, + source_entities, entity_to_graph, entity_to_doc_iri, - entity_to_is_ontology, + entity_to_classification, + direct_relation_pairs, + object_groups, ). """ state = _EntityCollectionState(known_entities=known_ontology_entities or set()) @@ -1543,7 +1539,9 @@ def _collect_all_entities( # Keep collection in the same URI space that rewrite/merge consumes # (unit.graph). Using graph_absolute here causes mapping keys to miss # during rewrite, because unit.graph still contains the original terms. - for s, p, o in unit.graph: + for triple in unit.graph: + state.context.add(triple) + s, p, o = triple if isinstance(s, URIRef) and isinstance(o, URIRef): # Structural guards key on *names*, as they did before # unit scoping: a subject mentioned in two units points at @@ -1560,10 +1558,12 @@ def _collect_all_entities( if isinstance(term, URIRef): self._register_entity(entity=term, unit=unit, state=state) + # One graph serves every entity: a representation reads only the + # triples that mention its entity, through the graph's indexes. return ( list(state.entities), state.source_entities, - state.entity_graphs, + dict.fromkeys(state.entities, state.context), state.entity_doc_iris, state.entity_classification, state.direct_relation_pairs, diff --git a/ontocast/tool/agg/normalizer.py b/ontocast/tool/agg/normalizer.py index b11206ec..21499e99 100644 --- a/ontocast/tool/agg/normalizer.py +++ b/ontocast/tool/agg/normalizer.py @@ -9,9 +9,10 @@ from __future__ import annotations from dataclasses import dataclass, field +from itertools import chain from typing import TYPE_CHECKING -from rdflib import RDF, RDFS, Literal, URIRef +from rdflib import RDF, RDFS, Graph, Literal, URIRef from rdflib.term import Node from ontocast.onto.constants import DEFAULT_IRI @@ -88,6 +89,23 @@ def __post_init__(self) -> None: ontology_iri: str | None = None +def triples_mentioning(entity: Node, graph: Graph) -> list[tuple[Node, Node, Node]]: + """Return the triples of ``graph`` with ``entity`` as subject, predicate or object. + + Reads the graph's indexes, so the cost is the entity's degree, not the + graph's size. A triple mentioning the entity twice is returned once. + """ + return list( + dict.fromkeys( + chain( + graph.triples((entity, None, None)), + graph.triples((None, entity, None)), + graph.triples((None, None, entity)), + ) + ) + ) + + @dataclass class EntityContext: """Semantic context extracted for one entity in a single graph scan.""" @@ -180,7 +198,8 @@ def extract_entity_context(self, entity: URIRef, graph: RDFGraph) -> EntityConte Args: entity: Entity to extract context for - graph: RDF graph containing the entity + graph: RDF graph containing the entity; only the triples that + mention it are read, so it may be arbitrarily large Returns: :class:`EntityContext` with types, properties, labels, alt labels, @@ -208,8 +227,7 @@ def extract_entity_context(self, entity: URIRef, graph: RDFGraph) -> EntityConte predicate_iri_objects: dict[URIRef, set[URIRef]] = {} schema_predicates = {RDF.type, RDFS.label, RDFS.comment} - # Extract information from triples - for s, p, o in graph: + for s, p, o in triples_mentioning(entity, graph): # When entity is subject if s == entity: if isinstance(p, URIRef): @@ -301,7 +319,7 @@ def _build_neighborhood_representation( "as_predicate": set(), } - triples_sorted = stable_sorted_triples(list(graph)) + triples_sorted = stable_sorted_triples(triples_mentioning(entity, graph)) for subj, pred, obj in triples_sorted: if subj == entity: sentence = ( diff --git a/ontocast/tool/chunk/prepare.py b/ontocast/tool/chunk/prepare.py index af684fd3..37273aa4 100644 --- a/ontocast/tool/chunk/prepare.py +++ b/ontocast/tool/chunk/prepare.py @@ -37,7 +37,11 @@ merge_doc_item_refs, starts_with_section_heading, ) -from ontocast.tool.chunk.sizing import merge_small_parts, split_by_measurement_density +from ontocast.tool.chunk.sizing import ( + DEFAULT_PART_SEPARATOR, + merge_small_parts, + split_by_measurement_density, +) from ontocast.util.measurement_lexicon import unit_adjacent_numbers from ontocast.util.optional import require @@ -463,6 +467,46 @@ def _forward_fill_section_labels( ) +_PLACEHOLDER_LINE = re.compile(r"") + + +def _is_bare_heading(text: str) -> bool: + """True when ``text`` is heading lines (and image placeholders) with no body.""" + lines = [line.strip() for line in text.splitlines() if line.strip()] + body = [line for line in lines if not _PLACEHOLDER_LINE.fullmatch(line)] + return bool(body) and len(markdown_headings(text)) == len(body) + + +def _attach_bare_headings(segments: list[PrepareSegment]) -> list[PrepareSegment]: + """Prepend each body-less heading segment to the segment that follows it. + + A heading titles the text after it, so the joined segment keeps that text's + label, headings and confidence: the heading lends it no label, and is + filtered with its body instead of becoming a unit with nothing to extract. + Trailing headings have no follower and are kept as they are. + """ + attached: list[PrepareSegment] = [] + pending: list[PrepareSegment] = [] + for segment in segments: + if _is_bare_heading(segment.text): + pending.append(segment) + continue + if pending: + refs: tuple[str, ...] = () + for part in (*pending, segment): + refs = merge_doc_item_refs(refs, part.doc_item_refs) + segment = replace( + segment, + text=DEFAULT_PART_SEPARATOR.join( + part.text.strip() for part in (*pending, segment) + ), + doc_item_refs=refs, + ) + pending = [] + attached.append(segment) + return attached + pending + + @dataclass(frozen=True) class SchemaDecision: """Which label schema a document is prepared against, and why. @@ -690,11 +734,6 @@ def _merge_prepared_chunks( while index < len(chunks) and chunks[index].section_label == label: run.append(chunks[index]) index += 1 - # Unlabeled chunks are not known to share a section — they are - # merely each unresolved. Merging a run of them would rebuild the - # cross-section chunks the outline fix just eliminated. - if label is None: - break texts = merge_small_parts( [chunk.text for chunk in run], @@ -945,6 +984,7 @@ async def prepare_content_units( batch_size=config.section_llm_batch_size, ) _forward_fill_section_labels(segments, schema) + segments = _attach_bare_headings(segments) unlabeled = sum(1 for s in segments if s.section_label is None) if unlabeled: diff --git a/ontocast/tool/facts_validation/__init__.py b/ontocast/tool/facts_validation/__init__.py index 44404c69..0c7ccb81 100644 --- a/ontocast/tool/facts_validation/__init__.py +++ b/ontocast/tool/facts_validation/__init__.py @@ -31,6 +31,7 @@ normalize_literals_against_schema, promote_degenerate_bounds, promote_degenerate_bounds_from_vocabulary, + repair_compact_iri_literals, repair_literal_type_objects, repair_property_aliases, resolve_code_literals, @@ -83,6 +84,7 @@ "promote_degenerate_bounds", "promote_degenerate_bounds_from_vocabulary", "record_facts_gate_metrics", + "repair_compact_iri_literals", "repair_literal_type_objects", "repair_property_aliases", "resolve_code_literals", diff --git a/ontocast/tool/facts_validation/literal_repair.py b/ontocast/tool/facts_validation/literal_repair.py index eccb9ac1..2dfeae55 100644 --- a/ontocast/tool/facts_validation/literal_repair.py +++ b/ontocast/tool/facts_validation/literal_repair.py @@ -26,6 +26,7 @@ RDFGraph, ) from ontocast.tool.facts_validation.terms import ( + _COMPACT_IRI, _alias_candidates, _declared_domains, _local_name, @@ -39,6 +40,8 @@ expand_vocabulary_terms, resolve_unique_surface, ) +from ontocast.tool.llm import record_active_count +from ontocast.tool.validate import _predicate_expects_iri_object from ontocast.util.numeric_inventory import canonical_number logger = logging.getLogger(__name__) @@ -222,6 +225,78 @@ def repair_literal_type_objects( return rewritten, findings, applied +def repair_compact_iri_literals( + graph: RDFGraph, + ontology_context_graph: RDFGraph, + known_terms: set[str], +) -> tuple[int, list[GraphRepairRecord]]: + """Coerce plain-string compact IRIs on IRI-valued positions into IRIs. + + A JSON-LD bare string (``"qudt:unit": "unit:NanoM"``) parses as a literal, + so the node loses its link and reads as missing the property. A literal is + rewritten only when every condition holds: it is plain (no language tag, + no datatype or ``xsd:string``); the predicate is not ``rdf:type`` (that is + :func:`repair_literal_type_objects`); the lexical form is a compact IRI + whose prefix the graph or the known-prefix context binds; and either the + schema says the predicate takes an IRI or the expanded IRI is a known term. + Prose such as ``"time: 10 minutes"`` never matches the compact-IRI form. + + Args: + graph: Facts graph, repaired in place. + ontology_context_graph: Schema that says which predicates take IRIs. + known_terms: IRIs the catalog and fallback vocabulary declare; covers + predicates whose range the vendored vocabulary leaves undeclared. + + Returns: + Tuple of (number of rewritten triples, applied-repair records). + """ + prefix_map = dict(RDFGraph.get_known_prefixes() or {}) + prefix_map.update( + {prefix: str(namespace) for prefix, namespace in graph.namespaces() if prefix} + ) + replacements: list[tuple[tuple[Node, Node, Node], URIRef, str]] = [] + expects_iri: dict[URIRef, bool] = {} + for subject, predicate, obj in graph: + if predicate == RDF.type or not isinstance(obj, Literal): + continue + if obj.language or obj.datatype not in (None, XSD.string): + continue + lexical = str(obj).strip() + match = _COMPACT_IRI.match(lexical) + if match is None: + continue + namespace = prefix_map.get(match.group(1)) + if not namespace: + continue + expanded = f"{namespace}{match.group(2)}" + if expanded not in known_terms: + if not isinstance(predicate, URIRef): + continue + if predicate not in expects_iri: + expects_iri[predicate] = _predicate_expects_iri_object( + predicate, ontology_context_graph + )[0] + if not expects_iri[predicate]: + continue + replacements.append(((subject, predicate, obj), URIRef(expanded), lexical)) + + applied: list[GraphRepairRecord] = [] + for (subject, predicate, obj), iri, lexical in replacements: + graph.remove((subject, predicate, obj)) + graph.add((subject, predicate, iri)) + applied.append( + GraphRepairRecord( + kind=FactsUnitFindingKind.COMPACT_IRI_LITERAL, + source=lexical, + target=str(iri), + ) + ) + logger.info("Repaired compact-IRI literal %r -> <%s>", lexical, iri) + if applied: + record_active_count("repair/compact_iri_literal", len(applied)) + return len(applied), applied + + def _folded_name(local: str) -> str: """A local name with case and separators removed, for spelling equality.""" return re.sub(r"[_\-]", "", local).lower() diff --git a/pyproject.toml b/pyproject.toml index 661a30b8..aa7d5ece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ license-files = ["LICENSE"] name = "ontocast" readme = "README.md" requires-python = ">=3.12,<4.0" -version = "0.6.6" +version = "0.6.7" [project.optional-dependencies] # `plot` is not included: it only serves `plot-graph`, which regenerates the diff --git a/test/aggregation/test_collect_scaling.py b/test/aggregation/test_collect_scaling.py new file mode 100644 index 00000000..edf65cd2 --- /dev/null +++ b/test/aggregation/test_collect_scaling.py @@ -0,0 +1,72 @@ +"""Entity collection is linear in the units, and an entity's context is its triples.""" + +import pytest +from rdflib import URIRef + +from ontocast.onto.content_unit import ContentUnit, OutputType +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.tool import EmbeddingBasedAggregator + +pytestmark = pytest.mark.unit + +DOC = "https://ex.org/doc/1" +ONTO = "https://ex.org/onto#" + + +def _unit(i: int) -> ContentUnit: + # Shared predicates and classes occur in every unit, as in a real document. + lines = [ + f"@prefix f: <{DOC}/> .", + f"@prefix o: <{ONTO}> .", + "@prefix rdfs: .", + ] + for j in range(20): + e = f"f:e{(i * 7 + j) % 300}" + lines.append( + f'{e} a o:C{j % 10} ; rdfs:label "e{j}" ; o:value "{i}.{j}" ; ' + f"o:next f:e{(i * 7 + j + 1) % 300} ." + ) + graph = RDFGraph() + graph.parse(data="\n".join(lines), format="turtle") + return ContentUnit( + text=f"unit {i}", index=i, doc_iri=DOC, graph=graph, type=OutputType.FACTS + ) + + +def _collection_adds(n_units: int, monkeypatch: pytest.MonkeyPatch) -> int: + calls = [0] + add = RDFGraph.add + + def counted(self, triple): + calls[0] += 1 + return add(self, triple) + + units = [_unit(i) for i in range(n_units)] + monkeypatch.setattr(RDFGraph, "add", counted) + EmbeddingBasedAggregator()._collect_all_entities(units, set()) + monkeypatch.undo() + return calls[0] + + +def test_collection_work_grows_linearly_with_units( + monkeypatch: pytest.MonkeyPatch, +) -> None: + small = _collection_adds(10, monkeypatch) + large = _collection_adds(40, monkeypatch) + # Four times the units: about four times the work, never the square. + assert large <= 5 * small + + +def test_entity_context_is_the_triples_that_mention_it() -> None: + units = [_unit(0), _unit(1)] + aggregator = EmbeddingBasedAggregator() + _, _, graphs, *_ = aggregator._collect_all_entities(units, set()) + for entity in (URIRef(f"{ONTO}next"), URIRef(f"{DOC}/e7")): + mentions = RDFGraph() + for unit in units: + for triple in unit.graph: + if entity in triple: + mentions.add(triple) + assert len(mentions) + create = aggregator.normalizer.create_representation + assert create(entity, graphs[entity]) == create(entity, mentions) diff --git a/test/aggregation/test_normalizer.py b/test/aggregation/test_normalizer.py index af880029..922cd936 100644 --- a/test/aggregation/test_normalizer.py +++ b/test/aggregation/test_normalizer.py @@ -53,6 +53,31 @@ def test_create_representation_collects_metadata(normalizer: EntityNormalizer) - assert representation.core_representation.startswith("test entity") +def test_create_representation_ignores_triples_not_mentioning_entity( + normalizer: EntityNormalizer, +) -> None: + ex = Namespace("http://example.org/") + own = [ + (ex.A, RDF.type, ex.Thing), + (ex.A, RDFS.label, Literal("Alpha")), + (ex.A, ex.knows, ex.A), + (ex.B, ex.knows, ex.A), + (ex.B, ex.A, Literal("1")), + ] + alone = RDFGraph() + crowded = RDFGraph() + for triple in own: + alone.add(triple) + crowded.add(triple) + for i in range(50): + crowded.add((ex[f"N{i}"], RDF.type, ex.Thing)) + crowded.add((ex[f"N{i}"], RDFS.label, Literal(f"Alpha {i}"))) + + assert normalizer.create_representation( + ex.A, crowded + ) == normalizer.create_representation(ex.A, alone) + + def test_create_representation_uses_alt_labels_when_no_rdfs_label( normalizer: EntityNormalizer, ) -> None: diff --git a/test/chunking/test_prepare.py b/test/chunking/test_prepare.py index be951cf5..ee052c90 100644 --- a/test/chunking/test_prepare.py +++ b/test/chunking/test_prepare.py @@ -399,8 +399,8 @@ def test_target_sections_selects_only_the_requested_section(): assert "spin coating" not in body -def test_unlabeled_chunks_from_distinct_sections_are_not_merged(): - """A run of unresolved chunks must not be merged back together.""" +def test_undersized_unlabeled_chunks_merge_and_stay_unlabeled(): + """Unresolved neighbours are sized together; the result claims no label.""" doc = doc_from_markdown_lines( "# Notes\nFirst unnamed section body text here.\n\n" "# Vocabulary\nSecond unnamed section body text here.\n" @@ -409,8 +409,39 @@ def test_unlabeled_chunks_from_distinct_sections_are_not_merged(): _prepare(doc, min_size=5000, max_size=8000, options=PrepareOptions()) ) - unlabeled = [chunk for chunk in chunks if chunk.section_label is None] - assert len(unlabeled) == 2 + assert len(chunks) == 1 + assert chunks[0].section_label is None + assert "First unnamed" in chunks[0].text + assert "Second unnamed" in chunks[0].text + + +def test_unlabeled_chunk_is_not_merged_into_a_labeled_one(): + """Size never smears a label onto text no tier recognised.""" + doc = doc_from_markdown_lines( + "# Introduction\nWe survey prior work on knowledge graphs.\n\n" + "# Vocabulary\nSecond unnamed section body text here.\n" + ) + chunks = asyncio.run( + _prepare(doc, min_size=5000, max_size=8000, options=PrepareOptions()) + ) + + assert [chunk.section_label for chunk in chunks] == ["introduction", None] + + +def test_bare_heading_joins_the_text_it_titles(): + """A heading with no body is no unit; it opens the next one, under its label.""" + doc = doc_from_markdown_lines( + "# Introduction\nWe survey prior work on knowledge graphs.\n\n" + "# Methods\n" + "# Vocabulary\nSecond unnamed section body text here.\n" + ) + chunks = asyncio.run( + _prepare(doc, min_size=1, max_size=8000, options=PrepareOptions()) + ) + + assert [chunk.section_label for chunk in chunks] == ["introduction", None] + assert chunks[1].text.startswith("## Methods") + assert "Second unnamed" in chunks[1].text # --- empty section selection ------------------------------------------------- diff --git a/test/conftest.py b/test/conftest.py index 6cb0b98f..7fe4c607 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -34,7 +34,12 @@ #: a stray RENDER_MODE=facts skips the entire ontology block while the suite #: still reports green -- so they are checked, and the ~200 storage/tuning #: settings are not. -_PIPELINE_MODE_SELECTORS = ("render_mode", "ontology_context_mode", "llm_graph_format") +_PIPELINE_MODE_SELECTORS = ( + "render_mode", + "ontology_context_mode", + "llm_graph_format", + "llm_output_layout", +) def _assert_pipeline_config_not_leaked() -> None: diff --git a/test/facts/test_compact_iri_repair.py b/test/facts/test_compact_iri_repair.py new file mode 100644 index 00000000..6643aeda --- /dev/null +++ b/test/facts/test_compact_iri_repair.py @@ -0,0 +1,143 @@ +"""Bare-string compact IRIs on IRI-valued positions are coerced back to IRIs. + +A JSON-LD bare string (``"qudt:unit": "unit:MilliEV"``) parses as a literal, so +the value node loses its unit link and reads as missing the property. +""" + +import pytest +from rdflib import Literal, URIRef + +from ontocast.onto.model import FactsUnitFindingKind +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.state import BudgetTracker +from ontocast.tool.facts_validation import repair_compact_iri_literals +from ontocast.tool.llm import use_budget_tracker + +pytestmark = pytest.mark.unit + +QUDT = "http://qudt.org/schema/qudt/" +UNIT = "http://qudt.org/vocab/unit/" +QQVAL = "https://growgraph.dev/ontologies/qqval#" +FACTS = "https://growgraph.dev/facts/" + +_PREFIXES = f""" +@prefix cd: <{FACTS}> . +@prefix qudt: <{QUDT}> . +@prefix unit: <{UNIT}> . +@prefix qqval: <{QQVAL}> . +@prefix rdfs: . +@prefix xsd: . +@prefix owl: . +""" + + +def _ontology() -> RDFGraph: + graph = RDFGraph() + graph.parse( + data=_PREFIXES + + """ + qqval:epistemicQualifier a owl:ObjectProperty . + qqval:note a owl:DatatypeProperty ; rdfs:range xsd:string . + """, + format="turtle", + ) + return graph + + +def _facts(body: str) -> RDFGraph: + graph = RDFGraph() + graph.parse(data=_PREFIXES + body, format="turtle") + return graph + + +def _subject(name: str) -> URIRef: + return URIRef(f"{FACTS}{name}") + + +def test_known_term_on_a_rangeless_predicate_is_coerced() -> None: + # qudt:unit carries no range in the context, as in vendored QUDT subsets; + # the unit individual is a known term, which is enough. + graph = _facts('cd:v qudt:unit "unit:MilliEV" .') + rewritten, records = repair_compact_iri_literals( + graph, _ontology(), {f"{UNIT}MilliEV"} + ) + assert rewritten == 1 + assert (_subject("v"), URIRef(f"{QUDT}unit"), URIRef(f"{UNIT}MilliEV")) in graph + assert [(r.kind, r.source, r.target) for r in records] == [ + (FactsUnitFindingKind.COMPACT_IRI_LITERAL, "unit:MilliEV", f"{UNIT}MilliEV") + ] + + +def test_object_property_takes_an_unknown_term() -> None: + graph = _facts('cd:v qqval:epistemicQualifier "qqval:Approximate" .') + rewritten, _ = repair_compact_iri_literals(graph, _ontology(), set()) + assert rewritten == 1 + assert ( + _subject("v"), + URIRef(f"{QQVAL}epistemicQualifier"), + URIRef(f"{QQVAL}Approximate"), + ) in graph + + +def test_xsd_string_literal_is_plain_enough() -> None: + graph = _facts('cd:v qudt:unit "unit:MilliEV"^^xsd:string .') + rewritten, _ = repair_compact_iri_literals(graph, _ontology(), {f"{UNIT}MilliEV"}) + assert rewritten == 1 + + +@pytest.mark.parametrize( + "body", + [ + # Prose with a colon is never a compact IRI. + 'cd:v qudt:unit "time: 10 minutes" .', + # A prefix nothing binds is not guessed. + 'cd:v qudt:unit "nobody:MilliEV" .', + # Language-tagged and non-string typed literals are values. + 'cd:v qudt:unit "unit:MilliEV"@en .', + 'cd:v qudt:unit "unit:MilliEV"^^xsd:token .', + # A datatype property keeps its string, even one shaped like a CURIE. + 'cd:v qqval:note "qqval:Approximate" .', + # rdf:type literals belong to repair_literal_type_objects. + 'cd:v a "qqval:Approximate" .', + ], +) +def test_values_that_are_not_iri_references_are_left_alone(body: str) -> None: + graph = _facts(body) + before = set(graph) + rewritten, records = repair_compact_iri_literals( + graph, _ontology(), {f"{UNIT}MilliEV"} + ) + assert rewritten == 0 + assert not records + assert set(graph) == before + + +def test_unknown_term_on_a_rangeless_predicate_is_left_alone() -> None: + graph = _facts('cd:v qudt:unit "unit:NotInCatalog" .') + rewritten, _ = repair_compact_iri_literals(graph, _ontology(), set()) + assert rewritten == 0 + assert (_subject("v"), URIRef(f"{QUDT}unit"), Literal("unit:NotInCatalog")) in graph + + +def test_known_prefix_context_resolves_an_undeclared_prefix() -> None: + graph = RDFGraph() + graph.add((_subject("v"), URIRef(f"{QUDT}unit"), Literal("unit:MilliEV"))) + RDFGraph.set_known_prefixes({"unit": UNIT}) + try: + rewritten, _ = repair_compact_iri_literals( + graph, _ontology(), {f"{UNIT}MilliEV"} + ) + finally: + RDFGraph.set_known_prefixes(None) + assert rewritten == 1 + + +def test_rewrites_are_counted() -> None: + graph = _facts( + 'cd:v qudt:unit "unit:MilliEV" ; qqval:epistemicQualifier "qqval:Exact" .' + ) + tracker = BudgetTracker() + with use_budget_tracker(tracker): + repair_compact_iri_literals(graph, _ontology(), {f"{UNIT}MilliEV"}) + assert tracker.counters == {"repair/compact_iri_literal": 2} + assert not [o for o in graph.objects(None, None) if isinstance(o, Literal)] diff --git a/test/facts/test_complete_facts.py b/test/facts/test_complete_facts.py index 4cd23cc4..cabd011e 100644 --- a/test/facts/test_complete_facts.py +++ b/test/facts/test_complete_facts.py @@ -93,6 +93,7 @@ def _atomic(*, completion_passes: int = 1) -> AtomicToolBox: SimpleNamespace( facts_completion_passes=completion_passes, facts_patch_policy=CriticPatchPolicy(), + quantity_fallback_vocabulary=dict(DEFAULT_QUANTITY_FALLBACK_VOCABULARY), additional_standard_namespaces=(), validation_policy=ValidationPolicy( quantity_fallback_vocabulary=dict(DEFAULT_QUANTITY_FALLBACK_VOCABULARY) @@ -277,6 +278,7 @@ def _facts_tools(*, completion_passes: int) -> object: SimpleNamespace( facts_critic_passes=0, facts_patch_policy=CriticPatchPolicy(), + quantity_fallback_vocabulary=None, additional_standard_namespaces=(), validation_policy=None, acceptance_policy=None, diff --git a/test/facts/test_context_computed_once.py b/test/facts/test_context_computed_once.py index e2a62555..bc3f7132 100644 --- a/test/facts/test_context_computed_once.py +++ b/test/facts/test_context_computed_once.py @@ -17,6 +17,7 @@ from ontocast.onto.content_unit import ContentUnit from ontocast.onto.enum import ( + LLMOutputLayout, OntologyChapterFormat, OntologyContextMode, OntologyContextScope, @@ -85,6 +86,7 @@ def _tools(*, context_from_units: bool = False) -> ToolBox: parallel_workers=4, ontology_context_max_triples=4000, ontology_chapter_format=OntologyChapterFormat.INHERIT, + llm_output_layout=LLMOutputLayout.COMPACT, ontology_text_caps=TextCaps(), ontology_context_scope=OntologyContextScope.UNIT, fanout_warmup_units=0, diff --git a/test/facts/test_critic_patch_pass.py b/test/facts/test_critic_patch_pass.py index fe3448d2..f5a71ed4 100644 --- a/test/facts/test_critic_patch_pass.py +++ b/test/facts/test_critic_patch_pass.py @@ -56,6 +56,7 @@ def _atomic() -> AtomicToolBox: SimpleNamespace( facts_critic_passes=1, facts_patch_policy=CriticPatchPolicy(), + quantity_fallback_vocabulary=None, additional_standard_namespaces=(), validation_policy=None, acceptance_policy=None, @@ -448,3 +449,33 @@ def test_the_finding_walk_per_fix_is_charged_to_deterministic_repair() -> None: _run(state, []) assert "repair/deterministic" in state.budget_tracker.node_durations + + +def test_a_bare_string_iri_in_a_patch_is_repaired_before_it_is_judged() -> None: + """Patches skip the render's parse-time repairs; the phase hook covers them.""" + qudt_unit = URIRef("http://qudt.org/schema/qudt/unit") + milli_ev = URIRef("http://qudt.org/vocab/unit/MilliEV") + state = _unit_state() + state.prompt_triple_index = build_triple_index(state.content_unit.graph) + payload = ( + '{"@context": {"qudt": "http://qudt.org/schema/qudt/", ' + '"unit": "http://qudt.org/vocab/unit/"}, ' + f'"@id": "{_VALUE}", "qudt:unit": "unit:MilliEV"}}' + ) + state.suggestions.actionable_fixes = [_fix("ADD", correct=payload)] + atomic = _atomic() + cast(SimpleNamespace, atomic).catalog_terms = lambda: {str(milli_ev)} + + # The unit loop installs the catalog's prefixes for the whole loop. + RDFGraph.set_known_prefixes({"unit": "http://qudt.org/vocab/unit/"}) + try: + outcome = _run(state, [], atomic=atomic) + finally: + RDFGraph.set_known_prefixes(None) + + assert outcome.applied == 1 + assert (_VALUE, qudt_unit, milli_ev) in state.content_unit.graph + assert (_VALUE, qudt_unit, Literal("unit:MilliEV")) not in state.content_unit.graph + assert FactsUnitFindingKind.COMPACT_IRI_LITERAL in { + record.kind for record in state.applied_repairs + } diff --git a/test/facts/test_document_scoped_context.py b/test/facts/test_document_scoped_context.py index 0f1de059..b6f5abc2 100644 --- a/test/facts/test_document_scoped_context.py +++ b/test/facts/test_document_scoped_context.py @@ -18,6 +18,7 @@ from ontocast.onto.content_unit import ContentUnit from ontocast.onto.enum import ( + LLMOutputLayout, OntologyAssemblyMode, OntologyChapterFormat, OntologyContextMode, @@ -84,6 +85,7 @@ def _tools(*, scope: OntologyContextScope, warmup: int = 0) -> ToolBox: parallel_workers=4, ontology_context_max_triples=4000, ontology_chapter_format=OntologyChapterFormat.INHERIT, + llm_output_layout=LLMOutputLayout.COMPACT, ontology_text_caps=TextCaps(), ontology_context_scope=scope, fanout_warmup_units=warmup, diff --git a/test/ontology/test_turtle_wire.py b/test/ontology/test_turtle_wire.py new file mode 100644 index 00000000..34a01412 --- /dev/null +++ b/test/ontology/test_turtle_wire.py @@ -0,0 +1,119 @@ +"""The ontology loop on the default wire: Turtle, compact layout. + +Its chapter follows the wire (``inherit``), so a unit state built with no +format reads and writes Turtle in both the update render and the critic. +""" + +import importlib +import json +from typing import cast + +import pytest +from rdflib import URIRef + +from ontocast.config import WebSearchConfig +from ontocast.onto.content_unit import ContentUnit +from ontocast.onto.enum import LLMGraphFormat, LLMOutputLayout +from ontocast.onto.model import GraphUpdateRenderReport, OntologyCritiqueReport +from ontocast.onto.ontology import Ontology +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.unit_states import UnitOntologyState +from ontocast.tool.atomic import AtomicToolBox +from ontocast.tool.llm import LLMTool +from test.snapshot_helpers import snapshot_from_ontology + +pytestmark = pytest.mark.unit + +render_ontology_module = importlib.import_module("ontocast.agent.render_ontology") +criticise_ontology_module = importlib.import_module("ontocast.agent.criticise_ontology") + + +class _FakeLLMProvider: + async def get_llm_tool(self, budget_tracker: object) -> LLMTool: + return cast(LLMTool, object()) + + +def _tools() -> AtomicToolBox: + return AtomicToolBox( + llm_provider=_FakeLLMProvider(), + web_search_config=WebSearchConfig(enabled=False), + ) + + +def _state() -> UnitOntologyState: + graph = RDFGraph() + graph.parse( + data=""" + @prefix onto: . + @prefix owl: . + @prefix rdfs: . + a owl:Ontology . + onto:Company a owl:Class ; rdfs:label "Company" . + """, + format="turtle", + ) + ontology = Ontology(iri="https://example.com/onto", graph=graph) + return UnitOntologyState( + content_unit=ContentUnit( + text="Alice works for ACME.", + index=0, + doc_iri=URIRef("https://example.com/doc/d1"), + ), + ontology_snapshot=snapshot_from_ontology(ontology), + ) + + +def _schema(format_instructions: str) -> dict: + body = format_instructions.split("```\n", 1)[1].split("\n```", 1)[0] + return json.loads(body) + + +def test_a_default_unit_state_is_on_the_turtle_compact_wire() -> None: + state = _state() + assert state.llm_graph_format is LLMGraphFormat.TURTLE + assert state.llm_output_layout is LLMOutputLayout.COMPACT + + +@pytest.mark.anyio +async def test_update_render_reads_and_writes_turtle(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_call(**kwargs): + captured.update(kwargs["prompt_kwargs"]) + return GraphUpdateRenderReport() + + monkeypatch.setattr(render_ontology_module, "call_llm_with_retry", fake_call) + await render_ontology_module.render_ontology_update(_state(), tools=_tools()) + + combined = "\n".join(str(value) for value in captured.values()) + assert "single Turtle string" in combined + assert "```ttl" in combined + assert "Inside a Turtle string" in combined + format_instructions = next( + str(value) for value in captured.values() if "JSON schema" in str(value) + ) + properties = _schema(format_instructions)["properties"] + assert properties["insert_graph"]["type"] == "string" + assert properties["delete_graph"]["type"] == "string" + + +@pytest.mark.anyio +async def test_critic_reads_an_inline_id_turtle_chapter(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_call(**kwargs): + captured.update(kwargs["prompt_kwargs"]) + return OntologyCritiqueReport( + success=True, + score=95, + systemic_critique_summary="Looks good.", + actionable_ontology_fixes=[], + ) + + monkeypatch.setattr(criticise_ontology_module, "call_llm_with_retry", fake_call) + await criticise_ontology_module.criticise_ontology(_state(), tools=_tools()) + + combined = "\n".join(str(value) for value in captured.values()) + assert "```ttl" in combined + assert "TRIPLE INDEX (id |" not in combined + assert "LLM_GRAPH_FORMAT=turtle" in combined diff --git a/test/test_atomic_loop_bounds.py b/test/test_atomic_loop_bounds.py index 74841623..34f7d34b 100644 --- a/test/test_atomic_loop_bounds.py +++ b/test/test_atomic_loop_bounds.py @@ -57,6 +57,7 @@ def _tools(critic_passes: int = 1, *, critic_min_triples: int = 0) -> ToolBox: SimpleNamespace( facts_critic_passes=critic_passes, facts_patch_policy=CriticPatchPolicy(), + quantity_fallback_vocabulary=None, ontology_critic_passes=critic_passes, ontology_patch_policy=CriticPatchPolicy(), additional_standard_namespaces=(), diff --git a/test/test_cli_server.py b/test/test_cli_server.py index 1c3973fa..6557034c 100644 --- a/test/test_cli_server.py +++ b/test/test_cli_server.py @@ -826,7 +826,8 @@ def test_dump_run_manifest_records_cost_and_configuration(tmp_path) -> None: # them. Two runs used to be able to differ several-fold in tokens with # nothing in either manifest to say why. assert payload["prompting"] == { - "llm_graph_format": "jsonld", + "llm_graph_format": "turtle", + "llm_output_layout": "compact", "ontology_chapter_format": "inherit", "ontology_context_scope": "unit", "fanout_warmup_units": 0, diff --git a/test/test_embedding_surface.py b/test/test_embedding_surface.py index b2146433..20f22e7e 100644 --- a/test/test_embedding_surface.py +++ b/test/test_embedding_surface.py @@ -288,3 +288,43 @@ def test_none_backend_disables_retrieval(tmp_path) -> None: tools = ToolBox(_config(tmp_path, VectorStoreBackend.NONE), llm=STUB_LLM) assert tools.vector_store is None assert tools.patch_retriever is None + + +class _EchoGraph: + """Stands in for a compiled OntoCast graph: returns the state it was given.""" + + async def ainvoke(self, state: AgentState, _config: Any) -> AgentState: + return state + + +@pytest.mark.anyio +@pytest.mark.parametrize("explicit", [False, True]) +async def test_node_runs_on_the_configured_wire_format( + toolbox: ToolBox, explicit: bool +) -> None: + """A mapping that leaves the format unset gets LLM_GRAPH_FORMAT; one that sets it wins.""" + from ontocast.integrations.langgraph import make_ontocast_node + from ontocast.onto.enum import LLMGraphFormat + + toolbox.config.server.llm_graph_format = LLMGraphFormat.JSONLD + seen: dict[str, LLMGraphFormat] = {} + + def to_state(_parent: Any) -> AgentState: + if explicit: + return AgentState(llm_graph_format=LLMGraphFormat.TURTLE) + return AgentState() + + def from_state(final: AgentState, _parent: Any) -> dict[str, Any]: + seen["format"] = final.llm_graph_format + return {} + + node = make_ontocast_node( + toolbox, + to_agent_state=to_state, + from_agent_state=from_state, + recursion_limit=10, + graph=cast(CompiledStateGraph, _EchoGraph()), + ) + await node({}, {}) + expected = LLMGraphFormat.TURTLE if explicit else LLMGraphFormat.JSONLD + assert seen["format"] is expected diff --git a/test/test_graph_format_profile.py b/test/test_graph_format_profile.py index 5062d6f5..c6279c10 100644 --- a/test/test_graph_format_profile.py +++ b/test/test_graph_format_profile.py @@ -427,3 +427,16 @@ def test_schema_includes_web_search_when_enabled( schema = schema_for_model(report_cls, fmt, web_search_enabled=True) assert "external_evidence_request" in schema.get("properties", {}) assert "ExternalEvidenceRequest" in schema.get("$defs", {}) + + +def test_jsonld_instructions_require_id_objects() -> None: + """A bare string is a JSON-LD literal; no instruction may offer it for an IRI.""" + profile = get_graph_format_profile(LLMGraphFormat.JSONLD) + for text in ( + profile.render_fresh_output_instruction(target="facts"), + profile.render_update_output_instruction(), + profile.critique_graph_instruction(), + ): + assert "plain compact IRI string" not in text + assert '{"@id": "prefix:local"}' in text + assert "literal" in text diff --git a/test/test_langchain_tools.py b/test/test_langchain_tools.py index cff5e98b..289eaaca 100644 --- a/test/test_langchain_tools.py +++ b/test/test_langchain_tools.py @@ -290,3 +290,24 @@ def fake_create(_tool_config, *, embedding, sparse_embedding): ToolBox(config, llm=STUB_LLM) assert captured["sparse_embedding"] is sentinel + + +@pytest.mark.anyio +async def test_extract_runs_on_the_configured_wire_format( + toolbox: ToolBox, monkeypatch: pytest.MonkeyPatch +) -> None: + """The tool builds its own state, so it must carry LLM_GRAPH_FORMAT over.""" + from ontocast.integrations import langchain as module + from ontocast.onto.enum import LLMGraphFormat + from ontocast.stategraph import unit_pipeline + + seen: dict[str, LLMGraphFormat] = {} + + async def fake_pipeline(state, _tools): + seen["format"] = state.llm_graph_format + return None, None + + monkeypatch.setattr(unit_pipeline, "run_unit_pipeline", fake_pipeline) + toolbox.config.server.llm_graph_format = LLMGraphFormat.JSONLD + await module._extract(toolbox, 1000).ainvoke({"text": "a short text"}) + assert seen["format"] is LLMGraphFormat.JSONLD diff --git a/test/test_llm_graph_format_default.py b/test/test_llm_graph_format_default.py index d7dfe103..f3bced2c 100644 --- a/test/test_llm_graph_format_default.py +++ b/test/test_llm_graph_format_default.py @@ -6,7 +6,7 @@ parsed, so a partial edit does not fail loudly -- it silently makes the effective default depend on whether you came in via the HTTP API, the batch CLI, ``run_unit_pipeline``, or a bare ``model_validate``. That is precisely how -the flip to JSON-LD could have gone wrong. +a change of default can go wrong. These assert the *declared* defaults rather than instantiating, because ``ServerConfig`` is a ``BaseSettings``: instantiating it reads the ambient @@ -18,7 +18,12 @@ from pydantic import BaseModel from ontocast.config.settings import ServerConfig -from ontocast.onto.enum import LLMGraphFormat, OntologyContextMode, RenderMode +from ontocast.onto.enum import ( + LLMGraphFormat, + LLMOutputLayout, + OntologyContextMode, + RenderMode, +) from ontocast.onto.llm_graph_payload import llm_graph_format_ctx from ontocast.onto.state import AgentState from ontocast.onto.unit_states import UnitState @@ -30,12 +35,17 @@ def _declared_default(model: type[BaseModel], field: str) -> object: return model.model_fields[field].default -def test_wire_format_default_is_jsonld_everywhere() -> None: +def test_wire_format_default_is_turtle_everywhere() -> None: for model in (ServerConfig, AgentState, UnitState): - assert _declared_default(model, "llm_graph_format") is LLMGraphFormat.JSONLD, ( + assert _declared_default(model, "llm_graph_format") is LLMGraphFormat.TURTLE, ( f"{model.__name__} disagrees on the default LLM wire format" ) - assert llm_graph_format_ctx.get() is LLMGraphFormat.JSONLD + assert llm_graph_format_ctx.get() is LLMGraphFormat.TURTLE + + +def test_output_layout_default_is_compact_everywhere() -> None: + for model in (ServerConfig, UnitState): + assert _declared_default(model, "llm_output_layout") is LLMOutputLayout.COMPACT def test_mode_selector_defaults_agree_between_config_and_state() -> None: diff --git a/test/test_max_visits_critic_propagation.py b/test/test_max_visits_critic_propagation.py index bdc50a8c..b4134faa 100644 --- a/test/test_max_visits_critic_propagation.py +++ b/test/test_max_visits_critic_propagation.py @@ -75,6 +75,7 @@ def _tools(critic_passes: int = 1) -> ToolBox: SimpleNamespace( facts_critic_passes=critic_passes, facts_patch_policy=CriticPatchPolicy(), + quantity_fallback_vocabulary=None, additional_standard_namespaces=(), validation_policy=None, acceptance_policy=None, diff --git a/test/test_output_layout.py b/test/test_output_layout.py new file mode 100644 index 00000000..d9ebb123 --- /dev/null +++ b/test/test_output_layout.py @@ -0,0 +1,119 @@ +"""LLM_OUTPUT_LAYOUT and the parse-recovery counters.""" + +import pytest + +import ontocast.onto.rdfgraph as rdfgraph_module +from ontocast.config.settings import ServerConfig +from ontocast.onto.enum import LLMGraphFormat, LLMOutputLayout +from ontocast.onto.model import FactsRenderReport +from ontocast.onto.rdfgraph import RDFGraph +from ontocast.onto.state import BudgetTracker +from ontocast.onto.unit_states import UnitFactsState +from ontocast.prompt.graph_format import get_graph_format_profile +from ontocast.tool.llm import use_budget_tracker + +pytestmark = pytest.mark.unit + + +def test_layout_defaults_to_compact() -> None: + assert ServerConfig.model_fields["llm_output_layout"].default == ( + LLMOutputLayout.COMPACT + ) + assert UnitFactsState.model_fields["llm_output_layout"].default == ( + LLMOutputLayout.COMPACT + ) + + +@pytest.mark.parametrize("fmt", list(LLMGraphFormat)) +def test_free_layout_adds_nothing_to_the_format_instructions( + fmt: LLMGraphFormat, +) -> None: + free = get_graph_format_profile(fmt, output_layout=LLMOutputLayout.FREE) + default = get_graph_format_profile(fmt) + assert free is default + assert "RESPONSE LAYOUT" not in free.format_instructions(FactsRenderReport) + + +def test_compact_jsonld_asks_for_minified_json_only() -> None: + profile = get_graph_format_profile( + LLMGraphFormat.JSONLD, output_layout=LLMOutputLayout.COMPACT + ) + text = profile.format_instructions(FactsRenderReport) + assert "RESPONSE LAYOUT" in text + assert "minified" in text + assert "Turtle string" not in text + + +def test_compact_turtle_also_flattens_the_turtle_string() -> None: + profile = get_graph_format_profile( + LLMGraphFormat.TURTLE, output_layout=LLMOutputLayout.COMPACT + ) + text = profile.format_instructions(FactsRenderReport) + assert "minified" in text + assert "Inside a Turtle string" in text + + +def test_layout_leaves_output_instructions_and_parsing_alone() -> None: + free = get_graph_format_profile(LLMGraphFormat.JSONLD) + compact = get_graph_format_profile( + LLMGraphFormat.JSONLD, output_layout=LLMOutputLayout.COMPACT + ) + assert free.render_fresh_output_instruction() == ( + compact.render_fresh_output_instruction() + ) + assert free.critique_graph_instruction() == compact.critique_graph_instruction() + minified = ( + '{"semantic_graph":{"@context":{"ex":"http://ex.org/"},' + '"@graph":[{"@id":"ex:a","ex:p":{"@id":"ex:b"}}]},' + '"ontology_relevance_score":90,"triples_generation_score":90}' + ) + report = compact.parse_report(FactsRenderReport, minified) + assert len(report.semantic_graph) == 1 + + +def test_one_line_turtle_subjects_parse() -> None: + graph = RDFGraph._from_turtle_str( + '@prefix ex: . ex:a ex:p ex:b ; ex:q "x" . ex:b ex:p ex:c .' + ) + assert len(graph) == 3 + + +def test_repaired_turtle_is_counted() -> None: + tracker = BudgetTracker() + with use_budget_tracker(tracker): + graph = RDFGraph._from_turtle_str( + "@prefix ex: .\nex:a ex:p ex:b ;" + ) + assert len(graph) == 1 + assert tracker.counters == {"rdf/turtle_repair": 1} + + +def test_clean_turtle_is_not_counted() -> None: + tracker = BudgetTracker() + with use_budget_tracker(tracker): + RDFGraph._from_turtle_str("@prefix ex: .\nex:a ex:p ex:b .") + assert tracker.counters == {} + + +def test_jsonld_rdflib_fallback_is_counted(monkeypatch: pytest.MonkeyPatch) -> None: + def reject(*args: object, **kwargs: object) -> str: + raise ValueError("normalization rejected") + + monkeypatch.setattr(rdfgraph_module.jsonld, "normalize", reject) + tracker = BudgetTracker() + with use_budget_tracker(tracker): + graph = RDFGraph._from_jsonld_obj( + { + "@context": {"ex": "http://ex.org/"}, + "@graph": [{"@id": "ex:a", "ex:p": {"@id": "ex:b"}}], + } + ) + assert len(graph) == 1 + assert tracker.counters == {"rdf/jsonld_rdflib_fallback": 1} + + +def test_recovery_without_a_tracker_is_a_no_op() -> None: + graph = RDFGraph._from_turtle_str( + "@prefix ex: .\nex:a ex:p ex:b ;" + ) + assert len(graph) == 1 diff --git a/test/test_pipeline.py b/test/test_pipeline.py index e52d110b..d985f89a 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -21,6 +21,7 @@ from ontocast.onto.content_unit import ContentUnit, OutputType, SourceUnit from ontocast.onto.enum import ( LLMGraphFormat, + LLMOutputLayout, OntologyContextMode, RenderMode, Status, @@ -135,6 +136,7 @@ async def fake_resolve(_state, _tools, _unit, **_kwargs): facts_critic_passes=1, ontology_critic_passes=1, facts_patch_policy=CriticPatchPolicy(), + quantity_fallback_vocabulary=None, ontology_patch_policy=CriticPatchPolicy(), additional_standard_namespaces=(), validation_policy=None, @@ -1267,6 +1269,7 @@ class DummyServerConfig: enable_ontology_consolidation = True ontology_max_triples = None ontology_context_max_triples = 4000 + llm_output_layout = LLMOutputLayout.COMPACT class DummyConfig: server = DummyServerConfig() diff --git a/test/test_rdfgraph_add.py b/test/test_rdfgraph_add.py new file mode 100644 index 00000000..bd29ac26 --- /dev/null +++ b/test/test_rdfgraph_add.py @@ -0,0 +1,64 @@ +"""``RDFGraph +=`` adds in place, at the cost of the right-hand side.""" + +import pytest +from rdflib import Literal, URIRef + +from ontocast.onto.rdfgraph import RDFGraph + +pytestmark = pytest.mark.unit + +EX = "https://ex.org/" + + +def _graph(n: int, *, prefix: str = "ex", namespace: str = EX) -> RDFGraph: + graph = RDFGraph() + graph.bind(prefix, namespace) + for i in range(n): + graph.add((URIRef(f"{namespace}s{i}"), URIRef(f"{namespace}p"), Literal(i))) + return graph + + +def _counting_adds(graph: RDFGraph, monkeypatch: pytest.MonkeyPatch) -> list[int]: + calls = [0] + add = graph.add + + def counted(triple): + calls[0] += 1 + return add(triple) + + monkeypatch.setattr(graph, "add", counted) + return calls + + +def test_iadd_keeps_identity_and_matches_add() -> None: + left, right = _graph(3), _graph(2, namespace="https://other.org/") + expected = left + right + alias = left + left += right + assert left is alias + assert set(left) == set(expected) + + +def test_iadd_costs_the_right_hand_side_only(monkeypatch: pytest.MonkeyPatch) -> None: + big = _graph(1000) + calls = _counting_adds(big, monkeypatch) + big += _graph(1, namespace="https://other.org/") + assert len(big) == 1001 + assert calls[0] == 1 + + +def test_iadd_renames_a_clashing_prefix_like_add() -> None: + left = _graph(1) + right = _graph(1, namespace="https://clash.org/") # same prefix "ex" + expected = dict((p, str(u)) for p, u in (left + right).namespaces()) + left += right + bound = dict((p, str(u)) for p, u in left.namespaces()) + assert bound["ex"] == EX + assert "https://clash.org/" in bound.values() + assert bound == expected + + +def test_iadd_with_itself_is_a_no_op() -> None: + graph = _graph(3) + graph += graph + assert len(graph) == 3 diff --git a/test/test_unit_pipeline.py b/test/test_unit_pipeline.py index e93f246b..a60641f1 100644 --- a/test/test_unit_pipeline.py +++ b/test/test_unit_pipeline.py @@ -6,6 +6,7 @@ from ontocast.onto.docling_helpers import plain_text_to_docling_doc from ontocast.onto.enum import ( + LLMOutputLayout, OntologyAssemblyMode, OntologyChapterFormat, OntologyContextScope, @@ -98,6 +99,7 @@ async def fake_facts_loop( ontology_max_triples=50_000, ontology_context_max_triples=4000, ontology_chapter_format=OntologyChapterFormat.INHERIT, + llm_output_layout=LLMOutputLayout.COMPACT, ontology_text_caps=TextCaps(), ontology_context_scope=OntologyContextScope.UNIT, fanout_warmup_units=0, @@ -161,6 +163,7 @@ async def fake_facts_loop( ontology_max_triples=50_000, ontology_context_max_triples=4000, ontology_chapter_format=OntologyChapterFormat.INHERIT, + llm_output_layout=LLMOutputLayout.COMPACT, ontology_text_caps=TextCaps(), ontology_context_scope=OntologyContextScope.UNIT, fanout_warmup_units=0, diff --git a/uv.lock b/uv.lock index 77e42ad6..f6088b2a 100644 --- a/uv.lock +++ b/uv.lock @@ -2751,7 +2751,7 @@ wheels = [ [[package]] name = "ontocast" -version = "0.6.6" +version = "0.6.7" source = { editable = "." } dependencies = [ { name = "httpx" },