From 8a89407229bf9bf73247fd51467bdcd652c89abf Mon Sep 17 00:00:00 2001 From: Takashi Yamashina Date: Tue, 4 Aug 2026 19:47:26 +0900 Subject: [PATCH 1/3] schema: close the post-split audit gaps (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unreachable_from joins ADR 0009 §6.3's traversal exclusion via the new Context::unreachable_from_excluding (explore_excluding's monomorphized-closure pattern): once a schema exists, schema:type edges are never a bridge in the coverage audit's walk and never reported as orphans, so a shared type name can no longer hide genuine orphans; regression test pins both halves of the flip. - Both core SDKs surface §8.3's warn-mode carrier instead of stripping it with the envelope: add_associations returns AddAssociationsResult {applied, issues, schema_violations} (breaking; pre-1.0), BatchApplyResult aggregates per chunk, ImportResult gains schemas/issues/schema_violations. Plumbed through the helper stack (_request_json_full/_post_full, requestJsonFull/postFull) beside the existing result-only path, never around it. - SDK schema surface parity with HTTP/MCP: put_schema, audit_schema, validate_schema in Python and TypeScript, decoding the shared SchemaAudit shape; recorded in sdk/spec/surface.yaml. - Live protocol manual catches up: the four schema routes (plus the pre-existing /drift/audit gap), schema_mode on GET /contexts' row shape, and no_schema in the stable error-code vocabulary. - Env-var docs: the two TAGURU_MCP_* knobs land in README, four missing KNOWN_KEYS land in getting-started's table; python-langchain's stale "PROMPT_VERSION 2" comment now matches its constant and its TypeScript twin. Closes #402 Claude-Session: https://claude.ai/code/session_01HqB7fXgCKSnDxT58PenLaS --- CHANGELOG.md | 67 ++++++++++ README.md | 11 +- docs/getting-started.html | 4 + docs/schema.html | 21 +++- .../src/taguru_langchain/_extract.py | 2 +- sdk/python/src/taguru/__init__.py | 14 +++ sdk/python/src/taguru/_async/client.py | 115 ++++++++++++++++-- sdk/python/src/taguru/_models.py | 115 +++++++++++++++++- sdk/python/src/taguru/_shared.py | 61 ++++++++-- sdk/python/src/taguru/_sync/client.py | 115 ++++++++++++++++-- .../tests/integration/test_full_loop.py | 4 +- sdk/python/tests/unit/test_get_schema.py | 90 +++++++++++++- .../unit/test_pagination_and_batching.py | 39 ++++++ sdk/python/tests/unit/test_retry.py | 4 +- sdk/spec/surface.yaml | 10 ++ sdk/typescript/src/client.ts | 115 ++++++++++++++++-- sdk/typescript/src/index.ts | 7 ++ sdk/typescript/src/models.ts | 102 +++++++++++++++- sdk/typescript/src/transport.ts | 77 ++++++++++-- .../tests/integration/client.test.ts | 4 +- sdk/typescript/tests/unit/get-schema.test.ts | 70 ++++++++++- sdk/typescript/tests/unit/retry.test.ts | 6 +- sdk/typescript/tests/unit/transport.test.ts | 24 +++- src/api/coverage.rs | 10 +- src/context/traverse.rs | 51 +++++++- src/llm-protocol.md | 10 +- tests/http_api/schema_type_label.rs | 62 +++++++++- 27 files changed, 1123 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bcacc9c..97874b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ Entries that change an on-disk format or a response shape say so. ## [Unreleased] ### Added +- Both core SDKs gain the rest of the schema surface: + `put_schema`/`putSchema`, `audit_schema`/`auditSchema`, and + `validate_schema`/`validateSchema`, alongside the existing + `get_schema`/`getSchema` — closing the parity gap with HTTP and MCP, + which already exposed all four; an SDK-only integration previously + had to drop to raw HTTP to install, audit, or dry-run a schema. + `audit`/`validate` decode the shared `SchemaAudit` shape + (`{total, violations: [{association, issues}], untyped_concepts, + undeclared_types, unknown_labels, reserved_alias_conflicts}`, + ADR 0009 §10) with `violations` paging like every other match list; + Python's `put_schema`/`validate_schema` accept either a plain mapping + or the decoded `SchemaDocument` dataclass. Recorded in + `sdk/spec/surface.yaml` like every other cross-language method. +- `POST /contexts/{name}/unreachable_from` joins ADR 0009 §6.3's + traversal exclusion, amending #381's three-exclusion list: once a + schema document is installed, `schema:type` edges are invisible to + the coverage audit — never a bridge in the reachability walk (a + shared type name would otherwise put every typed instance in one + reachable component and silently under-report genuine orphans, the + one failure mode this audit exists to catch) and never reported as + orphans themselves, the same "never reported, never a bridge" + contract `explore_excluding` documents. Backed by the new additive + `Context::unreachable_from_excluding` (the same + monomorphized-`visible`-closure pattern as `explore_excluding`, so + the unfiltered path pays nothing); gated, like every §6.3 exclusion, + on document existence alone, never `mode` — a schema-free context + answers byte-identically to before. - Schema metrics and a documentation reference page (#388, S10 of #218's ADR 0009 split §15) — closing the split: strict/warn's actual effect was previously invisible on `/metrics`, and the feature had no @@ -618,6 +645,24 @@ Entries that change an on-disk format or a response shape say so. host application's call. ### Changed +- **Breaking (SDKs):** `add_associations`/`addAssociations` returns + `AddAssociationsResult {applied, issues, schema_violations}` instead + of the bare applied count, and `BatchApplyResult`/`ImportResult` gain + `issues`/`schema_violations` fields (`ImportResult` also `schemas`, + one `SchemaImportOutcome` per `taguru_schema` record the stream + restored). Both SDKs previously unwrapped only the envelope's + `result`, which made ADR 0009 §8.3's `warn`-mode carrier — the + `issues`/`schema_violations` fields riding *beside* `result` on a + write whose associations violated the schema — unreachable through + the SDK by any means: a `strict` refusal's issues survive in the + error body, but flipping a context to `warn` silently hid the same + violations from SDK callers, the exact asymmetry §8.3's "identical + `Issue` values in both modes" contract exists to prevent. Migration: + `applied = ctx.add_associations(ops)` becomes + `ctx.add_associations(ops).applied`; a caller that ignored the + return value is unaffected, and every new field is empty/zero for + `off` mode, no schema, a conforming write, or a server predating the + fields. - `ManifestEntry`/`CheckpointFingerprint` (Rust) and their SDK checkpoint-fingerprint twins gain a `schema_digest` field (#386, S8 of #218's ADR 0009 split §11), so that swapping in a different schema @@ -660,6 +705,28 @@ Entries that change an on-disk format or a response shape say so. `events_path`); `isinstance`/attribute access and every field #351/#352 already published are unaffected. +### Fixed +- Documentation drift, in the live protocol manual first: the document + `GET /protocol` and every MCP `initialize.instructions` actually + serve (`src/llm-protocol.md`) had no route-table rows for + `GET/PUT /contexts/{name}/schema`, + `POST /contexts/{name}/schema/audit`, + `POST /contexts/{name}/schema/validate`, or the pre-existing + `POST /contexts/{name}/drift/audit`, no `schema_mode` in + `GET /contexts`' documented row shape, and no `no_schema` in the + stable error-`code` vocabulary — all three already shipped and + fixture-pinned. All added; `docs/schema.html` now also names the + directory row's `schema_mode` and the SDK schema methods, and its + §6.3 exclusion list includes the coverage audit. Env-var docs catch + up too: `docs/getting-started.html`'s table gains + `TAGURU_PASSAGES_WAL_MAX_BYTES`, `TAGURU_AUTH_FAIL_LIMIT_PER_MIN`, + `TAGURU_CROSS_SEARCH_CONCURRENCY`, and `TAGURU_EMBED_PARALLEL`, and + README's MCP section documents `TAGURU_MCP_MAX_CONCURRENT_TOOLS` + and `TAGURU_MCP_MAX_RESULT_BYTES` — previously in `taguru --help` + and `KNOWN_KEYS` only. One stale mirror comment + (`sdk/python-langchain/.../_extract.py`, "PROMPT_VERSION 2" over a + `PROMPT_VERSION = 3` constant) now matches its TypeScript twin. + ## [0.6.0] - 2026-08-01 ### Added diff --git a/README.md b/README.md index a7036f71..7fc54b4c 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,10 @@ round trip, request by request, is traced in the [walkthrough](https://t0k0sh1.github.io/taguru/mcp-rag-walkthrough.html). `taguru-mcp` also honors `TAGURU_MCP_TIMEOUT_SECS` (per-request budget -against the server, default 75 — raise it for a slow local model) and -`TAGURU_MCP_MAX_LINE_BYTES` (stdio frame cap). +against the server, default 75 — raise it for a slow local model), +`TAGURU_MCP_MAX_LINE_BYTES` (stdio frame cap), and +`TAGURU_MCP_MAX_CONCURRENT_TOOLS` (simultaneously in-flight tool +calls, default 8). The same tools are also served remotely: `POST /mcp` speaks the MCP Streamable HTTP transport (stateless profile — plain JSON responses, @@ -168,6 +170,11 @@ claude mcp add --transport http taguru https://your-host/mcp \ # name: "taguru", authorization_token: "…"}] ``` +On the server side, `TAGURU_MCP_MAX_RESULT_BYTES` (default 8 MiB) caps +how much of one tool's result `POST /mcp` will buffer — past it the +call fails naming the export escape hatches instead of buffering +forever. + claude.ai custom connectors (web and mobile) authenticate with OAuth instead of a pasted header: set `TAGURU_PUBLIC_URL`, point the connector at `https://your-host/mcp`, and approve the consent page by diff --git a/docs/getting-started.html b/docs/getting-started.html index 8ebc8aed..d21c7ecf 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -120,6 +120,7 @@

Key environment variables

TAGURU_FLUSH_SECS5Image flush interval. With the WAL on, this is freshness cadence, not a loss window TAGURU_WALonfsync every acknowledged write before applying it (a crash loses nothing). 0 restores the flush-interval loss window TAGURU_WAL_MAX_BYTES256 MiBPer-context WAL ceiling. Only approached when flushes keep failing; past it, writes are refused with 500 + TAGURU_PASSAGES_WAL_MAX_BYTES1 GiBPassage-log backstop, the sibling ceiling for the passage lane — engages only when compaction is stuck; 0 disables TAGURU_REPLICATE_URL—Object-storage bucket (s3:// / gs:// / az:// / file://) for continuous replication of the whole data directory, epoch-fenced; credentials via each cloud's default chain. Restore with taguru restore — or start a server on an empty directory with the same URL and it boots straight from the bucket (pinned contexts hydrate before the port opens, the rest on first touch). Unset = off TAGURU_REPLICATE_INTERVAL_MS1000Replication poll cadence — the steady-state RPO knob; per-lane lag is exported at /metrics TAGURU_TAKEOVERoff1 (or serve --take-over) acknowledges deposing the bucket's newest writer while it still looks alive (heartbeat within 300s, no clean stop). A cleanly stopped writer never needs it; starting a writer against a bucket IS the promotion act @@ -131,14 +132,17 @@

Key environment variables

TAGURU_EMBED_URL / _MODEL / _API_KEY—Semantic entry tier (OpenAI-compatible /embeddings). Unset keeps the entrance purely lexical TAGURU_EMBED_TIMEOUT_SECS60Per-attempt ceiling for one embedding provider round trip; a request's remaining budget bounds an attempt further. Three consecutive failed attempts open a circuit breaker — fast-fails for 30s, then one probe decides whether to close it TAGURU_EMBED_AUTOoffRe-embed only the changes on each flush. Recommended whenever agents drive the ingest (don't count on refresh being called) + TAGURU_EMBED_PARALLEL1Concurrent 128-item chunk dispatch for one context's gloss/passage embedding refresh (1 = sequential). Raise to match the provider's rate limit, not the core count; concurrent refreshes across contexts aren't serialized and multiply it TAGURU_EMBED_PASSAGESoffAlso embed paragraphs = the semantic side of the text lane. A corpus is orders of magnitude larger than its glosses, so the spend is opt-in TAGURU_PASSAGE_VECTOR_LIMIT20,000Ceiling on paragraph vectors held per context. Past it the lexical lane still serves every paragraph; only the semantic side goes partial (the refresh response reports the skips). The default is pinned above the approximate-search threshold (10,000, compiled in) by a compile-time assertion, so default configuration always has headroom to engage the index — a custom value set below the threshold isn't blocked, only logged once at boot TAGURU_SEMANTIC_FLOOR0.35Floor for the semantic entry tier. A property of the embedding model (default calibrated for text-embedding-3-large; ~0.2 for Bedrock's Titan V2) — taguru calibrate measures the right value TAGURU_PUBLIC_URL—Public base URL. Setting it enables OAuth on remote MCP (/mcp), which lets claude.ai custom connectors attach TAGURU_RATE_LIMIT_PER_MIN0 (off)Per-key request budget per minute. Enable it before leaving localhost + TAGURU_AUTH_FAIL_LIMIT_PER_MIN10Failed-auth attempts per source IP before 429 — the brute-force brake. 0 disables; coarse behind a proxy (one IP for everyone) TAGURU_REQUEST_TIMEOUT_SECS30Time budget per request. Raise to 60+ once an embedding provider is configured TAGURU_MAX_CONCURRENT_REQUESTS256Global in-flight ceiling. Excess requests are shed immediately with 503 + Retry-After; 0 disables TAGURU_MAX_CONCURRENT_HEAVY_OPS2Shared ceiling for vocabulary audits and context compactions. Excess calls are shed immediately with 503 + Retry-After; 0 disables + TAGURU_CROSS_SEARCH_CONCURRENCY4Member contexts searched in parallel by a single cross-context (group) recall/query/passage search TAGURU_AUTO_COMPACTonRatio-triggered auto-compaction: each flush tick rebuilds at most the one worst context whose dead ratio exceeds TAGURU_AUTO_COMPACT_RATIO (0.5 — dead weight outgrew live content), behind the heavy-ops ceiling above. 0 keeps compaction manual-only TAGURU_CONTEXT_QUOTAS—Per-context ceilings as one JSON object, {"sake": {"storage_bytes": …, "cache_bytes": …}} — each field optional, never both absent. storage_bytes refuses growth writes at the ceiling with 507 storage_full (retract, compact, and delete stay open — they are the ways back under); cache_bytes bounds the context's resident share, evicting the over-share context first under cache pressure. Declared quotas surface as taguru_context_quota_bytes next to the per-context usage gauges. A broken declaration refuses boot, like broken credentials diff --git a/docs/schema.html b/docs/schema.html index 23ffbd0d..8202e541 100644 --- a/docs/schema.html +++ b/docs/schema.html @@ -188,8 +188,10 @@

The reserved schema:type label

relations declares an entry named schema:type.

- And three exclusions keep it out of surfaces that were never meant to see it: it is never - traversed by activate/explore, it never appears in the extraction + And a set of exclusions keeps it out of surfaces that were never meant to see it: it is never + traversed by activate/explore or by + unreachable_from's coverage audit (where a shared type name would otherwise + bridge disconnected facts and hide genuine orphans), it never appears in the extraction vocabulary block or list_labels's default page, and type-name concepts are excluded from audit_vocabulary's twin sweep once a schema exists. The single gate for all of this is "an installed schema document exists," never "mode != @@ -255,9 +257,18 @@

HTTP and MCP surface

The MCP tools get_schema, put_schema, validate_schema, - and audit_schema round-trip onto these same four routes; add_associations - and import inherit write-time enforcement for free, since MCP is a pure mapping - onto the HTTP surface. + and audit_schema round-trip onto these same four routes, as do the Python and + TypeScript SDKs' get_schema/put_schema/validate_schema/ + audit_schema (getSchema/putSchema/… in TypeScript); + add_associations and import inherit write-time enforcement for free, + since MCP is a pure mapping onto the HTTP surface. +

+

+ For routing without a second call, GET /contexts's directory rows also carry a + read-only schema_mode — the installed document's own mode, or + null for a context that never installed one (never a bare off + standing in for "no document", the same distinction GET /schema's own 404 + draws).

diff --git a/sdk/python-langchain/src/taguru_langchain/_extract.py b/sdk/python-langchain/src/taguru_langchain/_extract.py index fb625e82..953af133 100644 --- a/sdk/python-langchain/src/taguru_langchain/_extract.py +++ b/sdk/python-langchain/src/taguru_langchain/_extract.py @@ -385,7 +385,7 @@ def corrective_message(parse_error: str, length_limited: bool, fact_budget: int) ) -# -- the prompt (mirrors extract.rs system_prompt, PROMPT_VERSION 2) ------------- +# -- the prompt (mirrors extract.rs system_prompt, PROMPT_VERSION 3) ------------- def system_prompt( diff --git a/sdk/python/src/taguru/__init__.py b/sdk/python/src/taguru/__init__.py index fd771fa0..a3887552 100644 --- a/sdk/python/src/taguru/__init__.py +++ b/sdk/python/src/taguru/__init__.py @@ -39,10 +39,13 @@ from ._models import ( Activation, ActivationPage, + AddAssociationsResult, AliasEntry, AliasPage, Association, Attribution, + AuditAliases, + AuditNames, BatchApplyResult, Bm25Explain, BudgetLimits, @@ -79,6 +82,7 @@ GroupPage, ImportOutcome, ImportResult, + Issue, LabelPage, LabelUsage, LaneEvidence, @@ -107,7 +111,10 @@ RetractAssociationOutcome, RetractOutcome, RetrievalResult, + SchemaAudit, SchemaDocument, + SchemaImportOutcome, + SchemaViolation, SearchContextPlan, SearchExplanation, SearchLanesPlan, @@ -189,10 +196,13 @@ # models "Activation", "ActivationPage", + "AddAssociationsResult", "AliasEntry", "AliasPage", "Association", "Attribution", + "AuditAliases", + "AuditNames", "BatchApplyResult", "Bm25Explain", "BudgetLimits", @@ -229,6 +239,7 @@ "GroupPage", "ImportOutcome", "ImportResult", + "Issue", "LabelPage", "LabelUsage", "LaneEvidence", @@ -257,7 +268,10 @@ "RetractAssociationOutcome", "RetractOutcome", "RetrievalResult", + "SchemaAudit", "SchemaDocument", + "SchemaImportOutcome", + "SchemaViolation", "SearchContextPlan", "SearchExplanation", "SearchLanesPlan", diff --git a/sdk/python/src/taguru/_async/client.py b/sdk/python/src/taguru/_async/client.py index 46cf2bc5..3b7fee2a 100644 --- a/sdk/python/src/taguru/_async/client.py +++ b/sdk/python/src/taguru/_async/client.py @@ -16,6 +16,7 @@ import tempfile import time from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence +from dataclasses import asdict from pathlib import Path from typing import Any @@ -28,6 +29,7 @@ from .._models import ( Activation, ActivationPage, + AddAssociationsResult, AliasEntry, AliasPage, Association, @@ -47,6 +49,7 @@ GroupEntry, GroupPage, ImportResult, + Issue, LabelPage, MatchPage, PassageHit, @@ -57,6 +60,7 @@ RetractAssociationOutcome, RetractOutcome, RetrievalResult, + SchemaAudit, SchemaDocument, SearchExplanation, SearchPlan, @@ -90,6 +94,7 @@ run_blocking, run_contract_probe, unwrap_envelope, + unwrap_envelope_full, ) from .._types import ( AssocOp, @@ -289,6 +294,20 @@ async def _request_json( response = await self._send(method, path, params=params, json_body=json_body, retry=retry) return unwrap_envelope(response) + async def _request_json_full( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + json_body: Any = None, + retry: RetryClass = RetryClass.SAFE, + ) -> tuple[Any, list[Issue], int]: + """``_request_json`` plus the envelope's warn-mode carrier — + ``(result, issues, schema_violations)``, see ``unwrap_envelope_full``.""" + response = await self._send(method, path, params=params, json_body=json_body, retry=retry) + return unwrap_envelope_full(response) + # -- server-level operations ------------------------------------------- async def health(self) -> None: @@ -326,7 +345,8 @@ async def import_batches(self, data: str | bytes) -> ImportResult: response = await self._send( "POST", "/import", content=content, content_type="application/x-ndjson" ) - return normalize_import_outcomes(unwrap_envelope(response)) + result, issues, schema_violations = unwrap_envelope_full(response) + return normalize_import_outcomes(result, issues, schema_violations) async def import_file(self, path: str | Path) -> ImportResult: """Apply an NDJSON batch file (see ``import_batches``).""" @@ -736,6 +756,15 @@ async def _post( "POST", self._path + suffix, json_body=json_body, retry=retry ) + async def _post_full( + self, suffix: str, json_body: Any = None, retry: RetryClass = RetryClass.SAFE + ) -> tuple[Any, list[Issue], int]: + """``_post`` plus the envelope's warn-mode carrier — see + ``unwrap_envelope_full``.""" + return await self._client._request_json_full( + "POST", self._path + suffix, json_body=json_body, retry=retry + ) + # -- entry resolution --------------------------------------------------- async def resolve( @@ -987,19 +1016,73 @@ async def get_schema(self) -> SchemaDocument: result = await self._client._request_json("GET", self._path + "/schema") return decode(SchemaDocument, result) # type: ignore[no-any-return] + async def put_schema(self, document: SchemaDocument | Mapping[str, Any]) -> SchemaDocument: + """Install (or replace) the context's schema document; returns it + as installed (ADR 0009 §5). + + Refuses (400) a document whose ``relations`` declare the reserved + ``schema:type`` label, and refuses to install over a persisted + label alias resolving to it — rename the alias first (§6.3). + Installing changes what ``strict`` refuses from this point on; + dry-run a candidate with ``validate_schema`` before flipping. + """ + body = asdict(document) if isinstance(document, SchemaDocument) else dict(document) + result = await self._client._request_json("PUT", self._path + "/schema", json_body=body) + return decode(SchemaDocument, result) # type: ignore[no-any-return] + + async def audit_schema( + self, *, limit: int | None = None, after: MatchCursor | None = None + ) -> SchemaAudit: + """Judge every live association against the installed document + (ADR 0009 §10) — the pre-existing violations ``strict`` can never + surface on its own, since a write entrance only judges a write as + it happens. Candidates for review, not verdicts; nothing is + auto-fixed. + + ``after`` resumes past the previous page's last violation; + ``total`` stays constant across pages. Raises ``NotFoundError`` + when the context has no schema installed (404 ``no_schema``). + """ + body = drop_none({"limit": limit, "after": after}) + result = await self._post("/schema/audit", body) + return decode(SchemaAudit, result) # type: ignore[no-any-return] + + async def validate_schema( + self, + document: SchemaDocument | Mapping[str, Any], + *, + limit: int | None = None, + after: MatchCursor | None = None, + ) -> SchemaAudit: + """The same judgment as ``audit_schema``, but over a PROPOSED + document that is never persisted — the pre-flight to run before a + ``strict`` flip (ADR 0009 §10). Works identically whether the + context already has a schema or none at all. + """ + doc = asdict(document) if isinstance(document, SchemaDocument) else dict(document) + body = drop_none({"document": doc, "limit": limit, "after": after}) + result = await self._post("/schema/validate", body) + return decode(SchemaAudit, result) # type: ignore[no-any-return] + # -- graph writes --------------------------------------------------------- - async def add_associations(self, associations: Sequence[AssocOp]) -> int: - """Assert a batch of associations; returns the applied count. + async def add_associations(self, associations: Sequence[AssocOp]) -> AddAssociationsResult: + """Assert a batch of associations. - Weight ACCUMULATES on re-assertion, so this call is never blindly - retried after an ambiguous transport failure. Server cap: 10,000 per - request (use ``add_associations_batched`` to auto-chunk). + Returns the applied count plus, for a context whose schema runs in + ``warn`` mode, the schema violations the write raised anyway + (ADR 0009 §8.3) — check ``result.issues`` where a ``strict`` + context would have raised. Weight ACCUMULATES on re-assertion, so + this call is never blindly retried after an ambiguous transport + failure. Server cap: 10,000 per request (use + ``add_associations_batched`` to auto-chunk). """ - result = await self._post( + result, issues, schema_violations = await self._post_full( "/associations", list(associations), retry=RetryClass.UNSAFE_ON_AMBIGUOUS ) - return int(result) + return AddAssociationsResult( + applied=int(result), issues=issues, schema_violations=schema_violations + ) async def add_associations_batched( self, @@ -1012,13 +1095,25 @@ async def add_associations_batched( Chunks are independent requests: a failure mid-way leaves earlier chunks applied (that is why this is a separate, opt-in method). + ``issues``/``schema_violations`` aggregate every chunk's warn-mode + carrier, in chunk order. """ applied = 0 chunks = 0 + issues: list[Issue] = [] + schema_violations = 0 for chunk in chunk_associations(list(associations), chunk_size, max_chunk_bytes): - applied += await self.add_associations(chunk) + outcome = await self.add_associations(chunk) + applied += outcome.applied + issues.extend(outcome.issues) + schema_violations += outcome.schema_violations chunks += 1 - return BatchApplyResult(applied=applied, chunks=chunks) + return BatchApplyResult( + applied=applied, + chunks=chunks, + issues=issues, + schema_violations=schema_violations, + ) async def retract_association( self, subject: str, label: str, object: str diff --git a/sdk/python/src/taguru/_models.py b/sdk/python/src/taguru/_models.py index 1dd804f8..ad2580b3 100644 --- a/sdk/python/src/taguru/_models.py +++ b/sdk/python/src/taguru/_models.py @@ -443,6 +443,52 @@ class SchemaDocument: relations: dict[str, RelationDef] +@dataclass(slots=True, frozen=True) +class AuditNames: + """One of :class:`SchemaAudit`'s name-list sections: the true count of + names the check surfaced, and a name-ordered prefix capped + server-side (at 100).""" + + total: int + names: list[str] + + +@dataclass(slots=True, frozen=True) +class AuditAliases: + """:class:`AuditNames`'s sibling for ``reserved_alias_conflicts`` + (``alias -> canonical``, not a bare name set), capped the same way.""" + + total: int + aliases: dict[str, str] + + +@dataclass(slots=True, frozen=True) +class SchemaViolation: + """One live association the schema audit flagged, alongside every + issue it raised.""" + + association: Association + issues: list[Issue] + + +@dataclass(slots=True, frozen=True) +class SchemaAudit: + """ADR 0009 §10's schema audit: five independent read-only checks over + the live graph in one response — candidates for review, not verdicts. + + Only ``violations`` pages (``total`` stays constant across pages, + like every other match list); the other four sections each carry + their own true count with a capped prefix. + """ + + total: int + violations: list[SchemaViolation] + untyped_concepts: AuditNames + undeclared_types: AuditNames + unknown_labels: AuditNames + reserved_alias_conflicts: AuditAliases + + @dataclass(slots=True, frozen=True) class AliasPage: """One page of aliases; the cursor spans both namespaces (concepts first).""" @@ -815,6 +861,55 @@ class CompactOutcome: aliases_dropped: int +@dataclass(slots=True, frozen=True) +class Issue: + """One path-addressed schema violation (ADR 0009 §8.1/§8.3). + + The same four fields a ``strict`` refusal's error body lists — under + ``warn`` they ride the success envelope instead, because the write + went ahead. ``path`` names the offending element of the request, + ``kind`` is the violation kind (e.g. ``"range"``, + ``"unknown_reference"``), and ``expected``/``actual`` say what the + schema wanted versus what the request carried. + """ + + path: str + kind: str + expected: str + actual: str + + +@dataclass(slots=True, frozen=True) +class SchemaImportOutcome: + """What installing one ``taguru_schema`` record via import accomplished. + + No outcome verb (unlike :class:`GroupImportOutcome`): the install + cannot distinguish itself from a no-op PUT of the identical document + (ADR 0009 §13). + """ + + context: str + mode: str + types: int + relations: int + + +@dataclass(slots=True, frozen=True) +class AddAssociationsResult: + """What ``add_associations`` accomplished, warn-mode issues included. + + ``issues`` is the (possibly truncated) violation list from the + response envelope; ``schema_violations`` is the true count behind it + (ADR 0009 §8.3). Both are empty/zero for ``off`` mode, no schema, or + a fully conforming write — and a ``strict`` violation raises instead, + so it never reaches this type. + """ + + applied: int + issues: list[Issue] = field(default_factory=list) + schema_violations: int = 0 + + @dataclass(slots=True, frozen=True) class ImportOutcome: """Outcome of one applied batch (one source's retract-then-apply).""" @@ -856,18 +951,34 @@ class GroupImportOutcome: @dataclass(slots=True, frozen=True) class ImportResult: - """What ``POST /import`` accomplished: per-batch outcomes plus any group restores.""" + """What ``POST /import`` accomplished: per-batch outcomes plus any + group restores and ``taguru_schema`` installs. + + ``issues``/``schema_violations`` are the response envelope's + warn-mode carrier (ADR 0009 §8.3), stream-wide; each batch's own + :attr:`ImportOutcome.schema_violations` breaks the count down + per source, surviving ``issues``' truncation. + """ batches: list[ImportOutcome] groups: list[GroupImportOutcome] + schemas: list[SchemaImportOutcome] = field(default_factory=list) + issues: list[Issue] = field(default_factory=list) + schema_violations: int = 0 @dataclass(slots=True, frozen=True) class BatchApplyResult: - """Outcome of ``add_associations_batched``: chunks are independent writes.""" + """Outcome of ``add_associations_batched``: chunks are independent writes. + + ``issues``/``schema_violations`` aggregate every chunk's warn-mode + carrier (ADR 0009 §8.3), in chunk order. + """ applied: int chunks: int + issues: list[Issue] = field(default_factory=list) + schema_violations: int = 0 @dataclass(slots=True, frozen=True) diff --git a/sdk/python/src/taguru/_shared.py b/sdk/python/src/taguru/_shared.py index cca125e0..2e44b413 100644 --- a/sdk/python/src/taguru/_shared.py +++ b/sdk/python/src/taguru/_shared.py @@ -12,7 +12,13 @@ from ._decode import decode from ._errors import TaguruError, error_for_status -from ._models import GroupImportOutcome, ImportOutcome, ImportResult +from ._models import ( + GroupImportOutcome, + ImportOutcome, + ImportResult, + Issue, + SchemaImportOutcome, +) from ._retry import parse_retry_after from ._types import AssocOp @@ -110,6 +116,18 @@ def raise_for_response(response: httpx.Response) -> NoReturn: def unwrap_envelope(response: httpx.Response) -> Any: """Extract ``result`` from the ``{"result", "status": "ok", "time"}`` envelope.""" + return unwrap_envelope_full(response)[0] + + +def unwrap_envelope_full(response: httpx.Response) -> tuple[Any, list[Issue], int]: + """``unwrap_envelope`` plus the envelope's warn-mode carrier. + + Returns ``(result, issues, schema_violations)`` — the latter two are + ADR 0009 §8.3's fields, riding *beside* ``result`` on a ``warn``-mode + write whose associations violated the context's schema. Both are + empty/zero on every other response (and on servers predating the + fields), so result-only callers go through ``unwrap_envelope``. + """ try: data = response.json() except ValueError as exc: @@ -119,7 +137,13 @@ def unwrap_envelope(response: httpx.Response) -> Any: body=response.text, ) from exc if isinstance(data, dict) and data.get("status") == "ok" and "result" in data: - return data["result"] + raw_issues = data.get("issues") + issues = ( + [decode(Issue, issue) for issue in raw_issues] if isinstance(raw_issues, list) else [] + ) + raw_violations = data.get("schema_violations") + violations = raw_violations if isinstance(raw_violations, int) else 0 + return data["result"], issues, violations raise TaguruError( "response is not the taguru envelope shape", status=response.status_code, @@ -127,19 +151,36 @@ def unwrap_envelope(response: httpx.Response) -> Any: ) -def normalize_import_outcomes(result: Any) -> ImportResult: - """Normalize /import's response to ``ImportResult(batches, groups)``. +def normalize_import_outcomes( + result: Any, issues: list[Issue] | None = None, schema_violations: int = 0 +) -> ImportResult: + """Normalize /import's response to :class:`ImportResult`. - Current servers always answer ``{batches: [...], groups: [...]}`` - (``groups`` omitted entirely when the stream carried none); servers - predating that change answered a bare outcome for a single batch — both - parse here, so callers never branch on response shape. + Current servers always answer ``{batches: [...], groups: [...], + schemas: [...]}`` (``groups``/``schemas`` omitted entirely when the + stream carried none); servers predating that change answered a bare + outcome for a single batch — both parse here, so callers never branch + on response shape. ``issues``/``schema_violations`` are the response + envelope's warn-mode carrier, passed through by ``import_batches``. """ + issues = issues if issues is not None else [] if isinstance(result, dict) and isinstance(result.get("batches"), list): batches = [decode(ImportOutcome, outcome) for outcome in result["batches"]] groups = [decode(GroupImportOutcome, group) for group in result.get("groups", [])] - return ImportResult(batches=batches, groups=groups) - return ImportResult(batches=[decode(ImportOutcome, result)], groups=[]) + schemas = [decode(SchemaImportOutcome, schema) for schema in result.get("schemas", [])] + return ImportResult( + batches=batches, + groups=groups, + schemas=schemas, + issues=issues, + schema_violations=schema_violations, + ) + return ImportResult( + batches=[decode(ImportOutcome, result)], + groups=[], + issues=issues, + schema_violations=schema_violations, + ) async def run_blocking(fn: Any, *args: Any) -> Any: diff --git a/sdk/python/src/taguru/_sync/client.py b/sdk/python/src/taguru/_sync/client.py index 0e2c13ce..cbbd47fa 100644 --- a/sdk/python/src/taguru/_sync/client.py +++ b/sdk/python/src/taguru/_sync/client.py @@ -10,6 +10,7 @@ import os import tempfile from collections.abc import Generator, Iterator, Mapping, Sequence +from dataclasses import asdict from pathlib import Path from typing import Any @@ -22,6 +23,7 @@ from .._models import ( Activation, ActivationPage, + AddAssociationsResult, AliasEntry, AliasPage, Association, @@ -41,6 +43,7 @@ GroupEntry, GroupPage, ImportResult, + Issue, LabelPage, MatchPage, PassageHit, @@ -51,6 +54,7 @@ RetractAssociationOutcome, RetractOutcome, RetrievalResult, + SchemaAudit, SchemaDocument, SearchExplanation, SearchPlan, @@ -84,6 +88,7 @@ call_blocking, run_contract_probe_once, unwrap_envelope, + unwrap_envelope_full, ) from .._types import ( AssocOp, @@ -283,6 +288,20 @@ def _request_json( response = self._send(method, path, params=params, json_body=json_body, retry=retry) return unwrap_envelope(response) + def _request_json_full( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + json_body: Any = None, + retry: RetryClass = RetryClass.SAFE, + ) -> tuple[Any, list[Issue], int]: + """``_request_json`` plus the envelope's warn-mode carrier — + ``(result, issues, schema_violations)``, see ``unwrap_envelope_full``.""" + response = self._send(method, path, params=params, json_body=json_body, retry=retry) + return unwrap_envelope_full(response) + # -- server-level operations ------------------------------------------- def health(self) -> None: @@ -320,7 +339,8 @@ def import_batches(self, data: str | bytes) -> ImportResult: response = self._send( "POST", "/import", content=content, content_type="application/x-ndjson" ) - return normalize_import_outcomes(unwrap_envelope(response)) + result, issues, schema_violations = unwrap_envelope_full(response) + return normalize_import_outcomes(result, issues, schema_violations) def import_file(self, path: str | Path) -> ImportResult: """Apply an NDJSON batch file (see ``import_batches``).""" @@ -726,6 +746,15 @@ def _post(self, suffix: str, json_body: Any = None, retry: RetryClass = RetryCla "POST", self._path + suffix, json_body=json_body, retry=retry ) + def _post_full( + self, suffix: str, json_body: Any = None, retry: RetryClass = RetryClass.SAFE + ) -> tuple[Any, list[Issue], int]: + """``_post`` plus the envelope's warn-mode carrier — see + ``unwrap_envelope_full``.""" + return self._client._request_json_full( + "POST", self._path + suffix, json_body=json_body, retry=retry + ) + # -- entry resolution --------------------------------------------------- def resolve( @@ -973,19 +1002,73 @@ def get_schema(self) -> SchemaDocument: result = self._client._request_json("GET", self._path + "/schema") return decode(SchemaDocument, result) # type: ignore[no-any-return] + def put_schema(self, document: SchemaDocument | Mapping[str, Any]) -> SchemaDocument: + """Install (or replace) the context's schema document; returns it + as installed (ADR 0009 §5). + + Refuses (400) a document whose ``relations`` declare the reserved + ``schema:type`` label, and refuses to install over a persisted + label alias resolving to it — rename the alias first (§6.3). + Installing changes what ``strict`` refuses from this point on; + dry-run a candidate with ``validate_schema`` before flipping. + """ + body = asdict(document) if isinstance(document, SchemaDocument) else dict(document) + result = self._client._request_json("PUT", self._path + "/schema", json_body=body) + return decode(SchemaDocument, result) # type: ignore[no-any-return] + + def audit_schema( + self, *, limit: int | None = None, after: MatchCursor | None = None + ) -> SchemaAudit: + """Judge every live association against the installed document + (ADR 0009 §10) — the pre-existing violations ``strict`` can never + surface on its own, since a write entrance only judges a write as + it happens. Candidates for review, not verdicts; nothing is + auto-fixed. + + ``after`` resumes past the previous page's last violation; + ``total`` stays constant across pages. Raises ``NotFoundError`` + when the context has no schema installed (404 ``no_schema``). + """ + body = drop_none({"limit": limit, "after": after}) + result = self._post("/schema/audit", body) + return decode(SchemaAudit, result) # type: ignore[no-any-return] + + def validate_schema( + self, + document: SchemaDocument | Mapping[str, Any], + *, + limit: int | None = None, + after: MatchCursor | None = None, + ) -> SchemaAudit: + """The same judgment as ``audit_schema``, but over a PROPOSED + document that is never persisted — the pre-flight to run before a + ``strict`` flip (ADR 0009 §10). Works identically whether the + context already has a schema or none at all. + """ + doc = asdict(document) if isinstance(document, SchemaDocument) else dict(document) + body = drop_none({"document": doc, "limit": limit, "after": after}) + result = self._post("/schema/validate", body) + return decode(SchemaAudit, result) # type: ignore[no-any-return] + # -- graph writes --------------------------------------------------------- - def add_associations(self, associations: Sequence[AssocOp]) -> int: - """Assert a batch of associations; returns the applied count. + def add_associations(self, associations: Sequence[AssocOp]) -> AddAssociationsResult: + """Assert a batch of associations. - Weight ACCUMULATES on re-assertion, so this call is never blindly - retried after an ambiguous transport failure. Server cap: 10,000 per - request (use ``add_associations_batched`` to auto-chunk). + Returns the applied count plus, for a context whose schema runs in + ``warn`` mode, the schema violations the write raised anyway + (ADR 0009 §8.3) — check ``result.issues`` where a ``strict`` + context would have raised. Weight ACCUMULATES on re-assertion, so + this call is never blindly retried after an ambiguous transport + failure. Server cap: 10,000 per request (use + ``add_associations_batched`` to auto-chunk). """ - result = self._post( + result, issues, schema_violations = self._post_full( "/associations", list(associations), retry=RetryClass.UNSAFE_ON_AMBIGUOUS ) - return int(result) + return AddAssociationsResult( + applied=int(result), issues=issues, schema_violations=schema_violations + ) def add_associations_batched( self, @@ -998,13 +1081,25 @@ def add_associations_batched( Chunks are independent requests: a failure mid-way leaves earlier chunks applied (that is why this is a separate, opt-in method). + ``issues``/``schema_violations`` aggregate every chunk's warn-mode + carrier, in chunk order. """ applied = 0 chunks = 0 + issues: list[Issue] = [] + schema_violations = 0 for chunk in chunk_associations(list(associations), chunk_size, max_chunk_bytes): - applied += self.add_associations(chunk) + outcome = self.add_associations(chunk) + applied += outcome.applied + issues.extend(outcome.issues) + schema_violations += outcome.schema_violations chunks += 1 - return BatchApplyResult(applied=applied, chunks=chunks) + return BatchApplyResult( + applied=applied, + chunks=chunks, + issues=issues, + schema_violations=schema_violations, + ) def retract_association( self, subject: str, label: str, object: str diff --git a/sdk/python/tests/integration/test_full_loop.py b/sdk/python/tests/integration/test_full_loop.py index 76f974a8..3c41a1a3 100644 --- a/sdk/python/tests/integration/test_full_loop.py +++ b/sdk/python/tests/integration/test_full_loop.py @@ -108,8 +108,8 @@ def test_associations_accumulate_weight_and_validate(client: Taguru, fresh_name: client.contexts.create(fresh_name) ctx = client.context(fresh_name) op = {"subject": "s", "label": "l", "object": "o", "weight": 1.0, "source": "a"} - assert ctx.add_associations([op]) == 1 - assert ctx.add_associations([{**op, "source": "b"}]) == 1 + assert ctx.add_associations([op]).applied == 1 + assert ctx.add_associations([{**op, "source": "b"}]).applied == 1 page = ctx.query(subject="s", label="l") assert page.total == 1 diff --git a/sdk/python/tests/unit/test_get_schema.py b/sdk/python/tests/unit/test_get_schema.py index 6215bb1d..7318540e 100644 --- a/sdk/python/tests/unit/test_get_schema.py +++ b/sdk/python/tests/unit/test_get_schema.py @@ -1,12 +1,17 @@ -"""``get_schema`` decodes into ``SchemaDocument``/``TypeDef``/``RelationDef`` -(ADR 0009 §5) — the same shape ``PUT /contexts/{name}/schema`` accepts and -``taguru extract --schema``/both LangChain ingesters consume.""" +"""The schema client surface: ``get_schema`` decodes into +``SchemaDocument``/``TypeDef``/``RelationDef`` (ADR 0009 §5) — the same +shape ``put_schema`` sends and ``taguru extract --schema``/both LangChain +ingesters consume — and ``audit_schema``/``validate_schema`` decode the +shared ``SchemaAudit`` shape (§10).""" from __future__ import annotations +import json from typing import Any -from taguru import NotFoundError, RelationDef, SchemaDocument, TypeDef +import httpx + +from taguru import NotFoundError, RelationDef, SchemaAudit, SchemaDocument, TypeDef from .conftest import err_response, ok_response, sync_client @@ -56,3 +61,80 @@ def test_get_schema_raises_not_found_when_the_context_has_no_schema() -> None: pass else: raise AssertionError("expected NotFoundError") + + +SCHEMA_AUDIT: dict[str, Any] = { + "total": 1, + "violations": [ + { + "association": { + "subject": "青嶺酒造", + "label": "杜氏", + "object": "広島", + "weight": 1.0, + "count": 1, + "attributions": [], + }, + "issues": [ + { + "path": "edge(青嶺酒造, 杜氏, 広島)", + "kind": "range", + "expected": "one of [Person]", + "actual": "Prefecture", + } + ], + } + ], + "untyped_concepts": {"total": 2, "names": ["広島", "青嶺"]}, + "undeclared_types": {"total": 0, "names": []}, + "unknown_labels": {"total": 0, "names": []}, + "reserved_alias_conflicts": {"total": 1, "aliases": {"種類": "schema:type"}}, +} + + +def test_put_schema_sends_the_document_and_decodes_the_installed_one() -> None: + seen: dict[str, Any] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen["method"] = req.method + seen["path"] = req.url.path + seen["body"] = json.loads(req.content) + return ok_response(SCHEMA_DOCUMENT) + + client = sync_client(handler) + # A plain mapping and the decoded dataclass must serialize identically. + installed = client.context("aomine").put_schema(SCHEMA_DOCUMENT) + assert seen["method"] == "PUT" + assert seen["path"] == "/contexts/aomine/schema" + assert seen["body"] == SCHEMA_DOCUMENT + assert isinstance(installed, SchemaDocument) + + reinstalled = client.context("aomine").put_schema(installed) + assert seen["body"] == SCHEMA_DOCUMENT + assert reinstalled.mode == "strict" + + +def test_audit_and_validate_schema_decode_the_shared_audit_shape() -> None: + seen: dict[str, Any] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen["path"] = req.url.path + seen["body"] = json.loads(req.content) + return ok_response(SCHEMA_AUDIT) + + client = sync_client(handler) + audit = client.context("aomine").audit_schema(limit=10) + assert seen["path"] == "/contexts/aomine/schema/audit" + assert seen["body"] == {"limit": 10} + assert isinstance(audit, SchemaAudit) + assert audit.total == 1 + violation = audit.violations[0] + assert violation.association.object == "広島" + assert violation.issues[0].kind == "range" + assert audit.untyped_concepts.names == ["広島", "青嶺"] + assert audit.reserved_alias_conflicts.aliases == {"種類": "schema:type"} + + validated = client.context("aomine").validate_schema(SCHEMA_DOCUMENT, limit=10) + assert seen["path"] == "/contexts/aomine/schema/validate" + assert seen["body"] == {"document": SCHEMA_DOCUMENT, "limit": 10} + assert validated.total == 1 diff --git a/sdk/python/tests/unit/test_pagination_and_batching.py b/sdk/python/tests/unit/test_pagination_and_batching.py index e53b6327..b658dbdb 100644 --- a/sdk/python/tests/unit/test_pagination_and_batching.py +++ b/sdk/python/tests/unit/test_pagination_and_batching.py @@ -154,4 +154,43 @@ def handler(req: httpx.Request) -> httpx.Response: ) assert result.applied == 5 assert result.chunks == 3 + assert result.issues == [] + assert result.schema_violations == 0 assert batches == [2, 2, 1] + + +def test_add_associations_surfaces_the_warn_mode_envelope_carrier() -> None: + """ADR 0009 §8.3: a ``warn``-mode write's violations ride the success + envelope beside ``result`` — the SDK must hand them to the caller, not + strip them with the envelope.""" + issue = { + "path": "associations[0].object", + "kind": "range", + "expected": "one of [Brewery]", + "actual": "Prefecture", + } + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "result": 1, + "status": "ok", + "time": 0.001, + "issues": [issue], + "schema_violations": 3, + }, + ) + + client = sync_client(handler) + outcome = client.context("sake").add_associations([_op(0)]) + assert outcome.applied == 1 + assert outcome.schema_violations == 3 + assert [(i.path, i.kind, i.expected, i.actual) for i in outcome.issues] == [ + (issue["path"], issue["kind"], issue["expected"], issue["actual"]) + ] + + # Batched aggregation carries the same fields through, in chunk order. + batched = client.context("sake").add_associations_batched([_op(0), _op(1)], chunk_size=1) + assert batched.schema_violations == 6 + assert len(batched.issues) == 2 diff --git a/sdk/python/tests/unit/test_retry.py b/sdk/python/tests/unit/test_retry.py index 8b5f6b14..9b9037c2 100644 --- a/sdk/python/tests/unit/test_retry.py +++ b/sdk/python/tests/unit/test_retry.py @@ -40,10 +40,10 @@ def test_429_retries_even_on_unsafe_write_route() -> None: """Rate limiting rejects before the handler runs — nothing was applied.""" handler = FlakyHandler(1, lambda: err_response(429, "budget", {"retry-after": "0"})) client = sync_client(handler) - applied = client.context("sake").add_associations( + outcome = client.context("sake").add_associations( [{"subject": "s", "label": "l", "object": "o", "weight": 1.0}] ) - assert applied == 0 + assert outcome.applied == 0 assert handler.calls == 2 diff --git a/sdk/spec/surface.yaml b/sdk/spec/surface.yaml index 48cb6ce3..1cfb61a2 100644 --- a/sdk/spec/surface.yaml +++ b/sdk/spec/surface.yaml @@ -108,6 +108,16 @@ classes: list_labels: { route: "GET /contexts/{name}/labels", options: [limit, after, prefix] } iter_labels: { route: "GET /contexts/{name}/labels", options: [limit, prefix] } get_schema: { route: "GET /contexts/{name}/schema" } + put_schema: + route: "PUT /contexts/{name}/schema" + args: [document] + audit_schema: + route: "POST /contexts/{name}/schema/audit" + options: [limit, after] + validate_schema: + route: "POST /contexts/{name}/schema/validate" + args: [document] + options: [limit, after] add_associations: route: "POST /contexts/{name}/associations" args: [associations] diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index fff15ae9..8dd067dd 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -10,6 +10,7 @@ import { IncompatibleServerError, NotFoundError, TaguruError, TransportError } f import type { Activation, ActivationPage, + AddAssociationsResult, AliasEntry, AliasPage, AssocOp, @@ -33,6 +34,7 @@ import type { GroupEntry, GroupPage, ImportResult, + Issue, LabelPage, LocatorSpec, MatchCursor, @@ -48,6 +50,7 @@ import type { RetractAssociationOutcome, RetractOutcome, RetrievalResult, + SchemaAudit, SchemaDocument, SearchExplanation, SearchPlan, @@ -85,6 +88,7 @@ import { sleep, sortedEntries, unwrapEnvelope, + unwrapEnvelopeFull, } from "./transport.js"; export interface TaguruOptions { @@ -327,6 +331,19 @@ export class Taguru { return unwrapEnvelope(response.status, response.text); } + /** + * @internal `requestJson` plus the envelope's warn-mode carrier — see + * `unwrapEnvelopeFull`. + */ + async requestJsonFull( + method: string, + path: string, + options: SendOptions = {}, + ): Promise<{ result: unknown; issues: Issue[]; schema_violations: number }> { + const response = await this.send(method, path, options); + return unwrapEnvelopeFull(response.status, response.text); + } + /** @internal */ streamUrl(path: string): { url: string; @@ -381,7 +398,11 @@ export class Taguru { content: data, contentType: "application/x-ndjson", }); - return normalizeImportOutcomes(unwrapEnvelope(response.status, response.text)); + const { result, issues, schema_violations } = unwrapEnvelopeFull( + response.status, + response.text, + ); + return normalizeImportOutcomes(result, issues, schema_violations); } /** Apply an NDJSON batch file (see `importBatches`). */ @@ -800,6 +821,15 @@ export class Context { return this.client.requestJson("POST", this.path + suffix, { jsonBody, retry }); } + /** `post` plus the envelope's warn-mode carrier — see `unwrapEnvelopeFull`. */ + private async postFull( + suffix: string, + jsonBody?: unknown, + retry?: RetryClass, + ): Promise<{ result: unknown; issues: Issue[]; schema_violations: number }> { + return this.client.requestJsonFull("POST", this.path + suffix, { jsonBody, retry }); + } + // -- entry resolution --------------------------------------------------- /** @@ -1044,24 +1074,84 @@ export class Context { return result as SchemaDocument; } + /** + * Install (or replace) the context's schema document; returns it as + * installed (ADR 0009 §5). Refuses (400) a document whose `relations` + * declare the reserved `schema:type` label, and refuses to install over + * a persisted label alias resolving to it — rename the alias first + * (§6.3). Installing changes what `strict` refuses from this point on; + * dry-run a candidate with `validateSchema` before flipping. + */ + async putSchema(document: SchemaDocument): Promise { + const result = await this.client.requestJson("PUT", `${this.path}/schema`, { + jsonBody: document, + }); + return result as SchemaDocument; + } + + /** + * Judge every live association against the installed document (ADR 0009 + * §10) — the pre-existing violations `strict` can never surface on its + * own, since a write entrance only judges a write as it happens. + * Candidates for review, not verdicts; nothing is auto-fixed. `after` + * resumes past the previous page's last violation; `total` stays + * constant across pages. Throws NotFoundError when the context has no + * schema installed (404 `no_schema`). + */ + async auditSchema( + options: { limit?: number; after?: MatchCursor } = {}, + ): Promise { + const result = await this.post( + "/schema/audit", + dropUndefined({ limit: options.limit, after: options.after }), + ); + return result as SchemaAudit; + } + + /** + * The same judgment as `auditSchema`, but over a PROPOSED document that + * is never persisted — the pre-flight to run before a `strict` flip + * (ADR 0009 §10). Works identically whether the context already has a + * schema or none at all. + */ + async validateSchema( + document: SchemaDocument, + options: { limit?: number; after?: MatchCursor } = {}, + ): Promise { + const result = await this.post( + "/schema/validate", + dropUndefined({ document, limit: options.limit, after: options.after }), + ); + return result as SchemaAudit; + } + // -- graph writes --------------------------------------------------------- /** - * Assert a batch of associations; returns the applied count. + * Assert a batch of associations. * - * Weight ACCUMULATES on re-assertion, so this call is never blindly retried - * after an ambiguous transport failure. Server cap: 10,000 per request (use - * `addAssociationsBatched` to auto-chunk). + * Returns the applied count plus, for a context whose schema runs in + * `warn` mode, the schema violations the write raised anyway (ADR 0009 + * §8.3) — check `result.issues` where a `strict` context would have + * thrown. Weight ACCUMULATES on re-assertion, so this call is never + * blindly retried after an ambiguous transport failure. Server cap: + * 10,000 per request (use `addAssociationsBatched` to auto-chunk). */ - async addAssociations(associations: AssocOp[]): Promise { - const result = await this.post("/associations", associations, "unsafe_on_ambiguous"); - return Number(result); + async addAssociations(associations: AssocOp[]): Promise { + const { result, issues, schema_violations } = await this.postFull( + "/associations", + associations, + "unsafe_on_ambiguous", + ); + return { applied: Number(result), issues, schema_violations }; } /** * Chunked `addAssociations` for arbitrarily large batches. Chunks are * independent requests: a failure mid-way leaves earlier chunks applied * (that is why this is a separate, opt-in method). + * `issues`/`schema_violations` aggregate every chunk's warn-mode carrier, + * in chunk order. */ async addAssociationsBatched( associations: AssocOp[], @@ -1071,11 +1161,16 @@ export class Context { const maxChunkBytes = options.max_chunk_bytes ?? MAX_CHUNK_BYTES; let applied = 0; let chunks = 0; + const issues: Issue[] = []; + let schemaViolations = 0; for (const chunk of chunkAssociations(associations, chunkSize, maxChunkBytes)) { - applied += await this.addAssociations(chunk); + const outcome = await this.addAssociations(chunk); + applied += outcome.applied; + issues.push(...outcome.issues); + schemaViolations += outcome.schema_violations; chunks += 1; } - return { applied, chunks }; + return { applied, chunks, issues, schema_violations: schemaViolations }; } /** diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 686bc7e3..f29e1b2f 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -43,11 +43,14 @@ export { citationKey, type Activation, type ActivationPage, + type AddAssociationsResult, type AliasEntry, type AliasPage, type AssocOp, type Association, type Attribution, + type AuditAliases, + type AuditNames, type BatchApplyResult, type Bm25Explain, type BudgetLimits, @@ -87,6 +90,7 @@ export { type GroupPage, type ImportOutcome, type ImportResult, + type Issue, type LabelPage, type LabelUsage, type LaneEvidence, @@ -119,7 +123,10 @@ export { type RetractAssociationOutcome, type RetractOutcome, type RetrievalResult, + type SchemaAudit, type SchemaDocument, + type SchemaImportOutcome, + type SchemaViolation, type SearchContextPlan, type SearchExplanation, type SearchLanesPlan, diff --git a/sdk/typescript/src/models.ts b/sdk/typescript/src/models.ts index ae19826d..eb0a3646 100644 --- a/sdk/typescript/src/models.ts +++ b/sdk/typescript/src/models.ts @@ -502,6 +502,49 @@ export interface SchemaDocument { relations: Record; } +/** + * One of `SchemaAudit`'s name-list sections: the true count of names the + * check surfaced, and a name-ordered prefix capped server-side (at 100). + */ +export interface AuditNames { + total: number; + names: string[]; +} + +/** + * `AuditNames`'s sibling for `reserved_alias_conflicts` (`alias -> + * canonical`, not a bare name set), capped the same way. + */ +export interface AuditAliases { + total: number; + aliases: Record; +} + +/** + * One live association the schema audit flagged, alongside every issue it + * raised. + */ +export interface SchemaViolation { + association: Association; + issues: Issue[]; +} + +/** + * ADR 0009 §10's schema audit: five independent read-only checks over the + * live graph in one response — candidates for review, not verdicts. Only + * `violations` pages (`total` stays constant across pages, like every + * other match list); the other four sections each carry their own true + * count with a capped prefix. + */ +export interface SchemaAudit { + total: number; + violations: SchemaViolation[]; + untyped_concepts: AuditNames; + undeclared_types: AuditNames; + unknown_labels: AuditNames; + reserved_alias_conflicts: AuditAliases; +} + // -- aliases --------------------------------------------------------------------- /** One page of aliases; the cursor spans both namespaces (concepts first). */ @@ -857,6 +900,46 @@ export interface CompactOutcome { aliases_dropped: number; } +/** + * One path-addressed schema violation (ADR 0009 §8.1/§8.3). The same four + * fields a `strict` refusal's error body lists — under `warn` they ride the + * success envelope instead, because the write went ahead. `path` names the + * offending element of the request, `kind` is the violation kind (e.g. + * `"range"`, `"unknown_reference"`), and `expected`/`actual` say what the + * schema wanted versus what the request carried. + */ +export interface Issue { + path: string; + kind: string; + expected: string; + actual: string; +} + +/** + * What installing one `taguru_schema` record via import accomplished. No + * outcome verb (unlike `GroupImportOutcome`): the install cannot distinguish + * itself from a no-op PUT of the identical document (ADR 0009 §13). + */ +export interface SchemaImportOutcome { + context: string; + mode: string; + types: number; + relations: number; +} + +/** + * What `addAssociations` accomplished, warn-mode issues included. `issues` + * is the (possibly truncated) violation list from the response envelope; + * `schema_violations` is the true count behind it (ADR 0009 §8.3). Both are + * empty/zero for `off` mode, no schema, or a fully conforming write — and a + * `strict` violation throws instead, so it never reaches this type. + */ +export interface AddAssociationsResult { + applied: number; + issues: Issue[]; + schema_violations: number; +} + /** Outcome of one applied batch (one source's retract-then-apply). */ export interface ImportOutcome { context: string; @@ -893,16 +976,31 @@ export interface GroupImportOutcome { groups: number; } -/** What `POST /import` accomplished: per-batch outcomes plus any group restores. */ +/** + * What `POST /import` accomplished: per-batch outcomes plus any group + * restores and `taguru_schema` installs. `issues`/`schema_violations` are + * the response envelope's warn-mode carrier (ADR 0009 §8.3), stream-wide; + * each batch's own `ImportOutcome.schema_violations` breaks the count down + * per source, surviving `issues`' truncation. + */ export interface ImportResult { batches: ImportOutcome[]; groups: GroupImportOutcome[]; + schemas: SchemaImportOutcome[]; + issues: Issue[]; + schema_violations: number; } -/** Outcome of `addAssociationsBatched`: chunks are independent writes. */ +/** + * Outcome of `addAssociationsBatched`: chunks are independent writes. + * `issues`/`schema_violations` aggregate every chunk's warn-mode carrier + * (ADR 0009 §8.3), in chunk order. + */ export interface BatchApplyResult { applied: number; chunks: number; + issues: Issue[]; + schema_violations: number; } /** diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts index 696d1804..84502906 100644 --- a/sdk/typescript/src/transport.ts +++ b/sdk/typescript/src/transport.ts @@ -1,7 +1,14 @@ /** Transport-independent pieces: envelope handling, error mapping, chunking. */ import { TaguruError, errorForStatus } from "./errors.js"; -import type { AssocOp, GroupImportOutcome, ImportOutcome, ImportResult } from "./models.js"; +import type { + AssocOp, + GroupImportOutcome, + ImportOutcome, + ImportResult, + Issue, + SchemaImportOutcome, +} from "./models.js"; import { parseRetryAfter } from "./retry.js"; export const DEFAULT_BASE_URL = "http://127.0.0.1:8248"; @@ -114,6 +121,20 @@ export function errorFromBody( /** Extract `result` from the `{"result", "status": "ok", "time"}` envelope. */ export function unwrapEnvelope(status: number, bodyText: string): unknown { + return unwrapEnvelopeFull(status, bodyText).result; +} + +/** + * `unwrapEnvelope` plus the envelope's warn-mode carrier: `issues` and + * `schema_violations` are ADR 0009 §8.3's fields, riding *beside* `result` + * on a `warn`-mode write whose associations violated the context's schema. + * Both are empty/zero on every other response (and on servers predating the + * fields), so result-only callers go through `unwrapEnvelope`. + */ +export function unwrapEnvelopeFull( + status: number, + bodyText: string, +): { result: unknown; issues: Issue[]; schema_violations: number } { let parsed: unknown; try { parsed = JSON.parse(bodyText); @@ -125,9 +146,19 @@ export function unwrapEnvelope(status: number, bodyText: string): unknown { }); } if (typeof parsed === "object" && parsed !== null && "result" in parsed) { - const shaped = parsed as { result: unknown; status?: unknown }; + const shaped = parsed as { + result: unknown; + status?: unknown; + issues?: unknown; + schema_violations?: unknown; + }; if (shaped.status === "ok") { - return shaped.result; + return { + result: shaped.result, + issues: Array.isArray(shaped.issues) ? (shaped.issues as Issue[]) : [], + schema_violations: + typeof shaped.schema_violations === "number" ? shaped.schema_violations : 0, + }; } } throw new TaguruError("response is not the taguru envelope shape", { @@ -137,22 +168,44 @@ export function unwrapEnvelope(status: number, bodyText: string): unknown { } /** - * Normalize /import's response to `{batches, groups}`. Current servers - * always answer `{batches: [...], groups: [...]}` (`groups` omitted - * entirely when the stream carried none); servers predating that change - * answered a bare outcome for a single batch — both parse here, so callers - * never branch on response shape. + * Normalize /import's response to an `ImportResult`. Current servers always + * answer `{batches: [...], groups: [...], schemas: [...]}` + * (`groups`/`schemas` omitted entirely when the stream carried none); + * servers predating that change answered a bare outcome for a single batch + * — both parse here, so callers never branch on response shape. + * `issues`/`schema_violations` are the response envelope's warn-mode + * carrier, passed through by `importBatches`. */ -export function normalizeImportOutcomes(result: unknown): ImportResult { +export function normalizeImportOutcomes( + result: unknown, + issues: Issue[] = [], + schema_violations = 0, +): ImportResult { if ( typeof result === "object" && result !== null && Array.isArray((result as { batches?: unknown }).batches) ) { - const shaped = result as { batches: ImportOutcome[]; groups?: GroupImportOutcome[] }; - return { batches: shaped.batches, groups: shaped.groups ?? [] }; + const shaped = result as { + batches: ImportOutcome[]; + groups?: GroupImportOutcome[]; + schemas?: SchemaImportOutcome[]; + }; + return { + batches: shaped.batches, + groups: shaped.groups ?? [], + schemas: shaped.schemas ?? [], + issues, + schema_violations, + }; } - return { batches: [result as ImportOutcome], groups: [] }; + return { + batches: [result as ImportOutcome], + groups: [], + schemas: [], + issues, + schema_violations, + }; } /** Percent-encode one path segment (context names may be any UTF-8). */ diff --git a/sdk/typescript/tests/integration/client.test.ts b/sdk/typescript/tests/integration/client.test.ts index 39c2c02b..1a760ffb 100644 --- a/sdk/typescript/tests/integration/client.test.ts +++ b/sdk/typescript/tests/integration/client.test.ts @@ -112,8 +112,8 @@ describe("graph writes and reads", () => { await client.contexts.create(name); const ctx = client.context(name); const op = { subject: "s", label: "l", object: "o", weight: 1.0, source: "a" }; - expect(await ctx.addAssociations([op])).toBe(1); - expect(await ctx.addAssociations([{ ...op, source: "b" }])).toBe(1); + expect((await ctx.addAssociations([op])).applied).toBe(1); + expect((await ctx.addAssociations([{ ...op, source: "b" }])).applied).toBe(1); const page = await ctx.query({ subject: "s", label: "l" }); expect(page.total).toBe(1); diff --git a/sdk/typescript/tests/unit/get-schema.test.ts b/sdk/typescript/tests/unit/get-schema.test.ts index cc72e802..ce179477 100644 --- a/sdk/typescript/tests/unit/get-schema.test.ts +++ b/sdk/typescript/tests/unit/get-schema.test.ts @@ -7,8 +7,8 @@ import { describe, expect, it } from "vitest"; import { NotFoundError } from "../../src/errors.js"; -import type { SchemaDocument } from "../../src/models.js"; -import { errBody, okBody, stubClient } from "./stub.js"; +import type { SchemaAudit, SchemaDocument } from "../../src/models.js"; +import { type StubRequest, errBody, okBody, stubClient } from "./stub.js"; const SCHEMA_DOCUMENT: SchemaDocument = { schema: 1, @@ -49,3 +49,69 @@ describe("getSchema", () => { expect((notFound as NotFoundError).code).toBe("no_schema"); }); }); + +const SCHEMA_AUDIT: SchemaAudit = { + total: 1, + violations: [ + { + association: { + subject: "青嶺酒造", + label: "杜氏", + object: "広島", + weight: 1.0, + count: 1, + attributions: [], + }, + issues: [ + { + path: "edge(青嶺酒造, 杜氏, 広島)", + kind: "range", + expected: "one of [Person]", + actual: "Prefecture", + }, + ], + }, + ], + untyped_concepts: { total: 2, names: ["広島", "青嶺"] }, + undeclared_types: { total: 0, names: [] }, + unknown_labels: { total: 0, names: [] }, + reserved_alias_conflicts: { total: 1, aliases: { 種類: "schema:type" } }, +}; + +describe("putSchema / auditSchema / validateSchema", () => { + it("putSchema PUTs the document and returns it as installed", async () => { + const requests: StubRequest[] = []; + const client = stubClient((req) => { + requests.push(req); + return okBody(SCHEMA_DOCUMENT); + }); + const installed = await client.context("aomine").putSchema(SCHEMA_DOCUMENT); + expect(requests[0]?.method).toBe("PUT"); + expect(requests[0]?.path).toBe("/contexts/aomine/schema"); + expect(JSON.parse(requests[0]?.body ?? "")).toEqual(SCHEMA_DOCUMENT); + expect(installed.mode).toBe("strict"); + }); + + it("auditSchema and validateSchema decode the shared audit shape", async () => { + const requests: StubRequest[] = []; + const client = stubClient((req) => { + requests.push(req); + return okBody(SCHEMA_AUDIT); + }); + + const audit = await client.context("aomine").auditSchema({ limit: 10 }); + expect(requests[0]?.path).toBe("/contexts/aomine/schema/audit"); + expect(JSON.parse(requests[0]?.body ?? "")).toEqual({ limit: 10 }); + expect(audit.total).toBe(1); + expect(audit.violations[0]?.association.object).toBe("広島"); + expect(audit.violations[0]?.issues[0]?.kind).toBe("range"); + expect(audit.reserved_alias_conflicts.aliases).toEqual({ 種類: "schema:type" }); + + const validated = await client + .context("aomine") + .validateSchema(SCHEMA_DOCUMENT, { limit: 10 }); + expect(requests[1]?.path).toBe("/contexts/aomine/schema/validate"); + expect(JSON.parse(requests[1]?.body ?? "")).toEqual({ document: SCHEMA_DOCUMENT, limit: 10 }); + expect(validated.total).toBe(1); + }); +}); diff --git a/sdk/typescript/tests/unit/retry.test.ts b/sdk/typescript/tests/unit/retry.test.ts index 5fb8be1b..ccde8225 100644 --- a/sdk/typescript/tests/unit/retry.test.ts +++ b/sdk/typescript/tests/unit/retry.test.ts @@ -42,7 +42,11 @@ describe("retry policy", () => { it("retries 429 even on the unsafe write route (shed before executing)", async () => { const { handler, calls } = flaky(1, () => errBody(429, "budget", { "retry-after": "0" })); const client = stubClient(handler); - await expect(client.context("sake").addAssociations([OP])).resolves.toBe(0); + await expect(client.context("sake").addAssociations([OP])).resolves.toEqual({ + applied: 0, + issues: [], + schema_violations: 0, + }); expect(calls()).toBe(2); }); diff --git a/sdk/typescript/tests/unit/transport.test.ts b/sdk/typescript/tests/unit/transport.test.ts index 81700a32..78e94f0f 100644 --- a/sdk/typescript/tests/unit/transport.test.ts +++ b/sdk/typescript/tests/unit/transport.test.ts @@ -377,9 +377,31 @@ describe("batching", () => { const result = await client .context("sake") .addAssociationsBatched([op(0), op(1), op(2), op(3), op(4)], { chunk_size: 2 }); - expect(result).toEqual({ applied: 5, chunks: 3 }); + expect(result).toEqual({ applied: 5, chunks: 3, issues: [], schema_violations: 0 }); expect(batchSizes).toEqual([2, 2, 1]); }); + + it("addAssociations surfaces the warn-mode envelope carrier", async () => { + const issue = { + path: "associations[0].object", + kind: "range", + expected: "one of [Brewery]", + actual: "Prefecture", + }; + const client = stubClient(() => ({ + status: 200, + headers: {}, + body: JSON.stringify({ + result: 1, + status: "ok", + time: 0.001, + issues: [issue], + schema_violations: 3, + }), + })); + const result = await client.context("sake").addAssociations([op(0)]); + expect(result).toEqual({ applied: 1, issues: [issue], schema_violations: 3 }); + }); }); describe("retrieve loop", () => { diff --git a/src/api/coverage.rs b/src/api/coverage.rs index 75a897df..25487345 100644 --- a/src/api/coverage.rs +++ b/src/api/coverage.rs @@ -248,12 +248,20 @@ pub async fn unreachable_from( if deadline.expired() { return deadline_exceeded(started_at); } + // ADR 0009 §6.3's traversal exclusion, same as `explore`/`activate`: + // a `schema:type` edge to a shared type name is exactly the hub that + // would bridge otherwise-disconnected facts and under-report orphans. + // Resolved before `read_context` — `AppState::hidden_label`'s slow + // path takes this entry's write lock, which would deadlock against + // `read_context`'s read lock held for the whole closure. + let hidden = tokio::task::block_in_place(|| state.hidden_label(&name)); + let excluded: Vec<&str> = hidden.into_iter().collect(); let loaded = tokio::task::block_in_place(|| { state .read_context(&name, |context| { let origins: Vec<&str> = request.origins.iter().map(String::as_str).collect(); context - .unreachable_from(&origins, deadline) + .unreachable_from_excluding(&origins, deadline, &excluded) .map_err(|_| AccessError::DeadlineExceeded) }) .and_then(std::convert::identity) diff --git a/src/context/traverse.rs b/src/context/traverse.rs index 0c2aee98..dfb5db87 100644 --- a/src/context/traverse.rs +++ b/src/context/traverse.rs @@ -465,6 +465,48 @@ impl Context { &self, origins: &[&str], deadline: Deadline, + ) -> Result, DeadlineExceeded> { + self.unreachable_from_impl(origins, deadline, |_| true) + } + + /// [`Context::unreachable_from`] with a set of relation labels hidden + /// from the audit entirely — never a bridge in the reachability walk, + /// never reported as an orphan. This is ADR 0009 §6.3's traversal + /// exclusion applied to the coverage audit, for the same reason + /// [`Context::explore_excluding`] exists: a hub label (many concepts + /// sharing one type) would otherwise connect every typed instance to + /// every other, silently under-reporting facts that are genuinely + /// disconnected from the origins in every non-typing sense. The + /// report side is excluded too — a hidden edge is invisible to this + /// audit, not a fact it should flag — mirroring `explore_excluding`'s + /// "never reported, never a bridge" contract rather than inventing a + /// third semantics. + /// + /// Additive alongside [`Context::unreachable_from`] rather than a + /// signature change, since `Context` is published API — the same + /// framing as [`Context::explore_excluding`], with which it also + /// shares the monomorphized-`visible`-closure structure (the + /// unfiltered caller's `|_| true` folds away entirely). + pub fn unreachable_from_excluding( + &self, + origins: &[&str], + deadline: Deadline, + excluded: &[&str], + ) -> Result, DeadlineExceeded> { + let excluded_ids: HashSet = excluded + .iter() + .filter_map(|name| self.label_ids.get(*name).copied()) + .collect(); + self.unreachable_from_impl(origins, deadline, move |label: LabelId| { + !excluded_ids.contains(&label) + }) + } + + fn unreachable_from_impl( + &self, + origins: &[&str], + deadline: Deadline, + visible: impl Fn(LabelId) -> bool, ) -> Result, DeadlineExceeded> { let mut visited: HashSet = HashSet::new(); let mut frontier: VecDeque = VecDeque::new(); @@ -485,7 +527,7 @@ impl Context { // chains and must not act as a bridge between otherwise // disconnected live facts — same dead-edge test as // `explore` and `heaviest`. - if edge.count == 0 { + if edge.count == 0 || !visible(edge.label) { continue; } for neighbor in [edge.subject, edge.object] { @@ -498,15 +540,16 @@ impl Context { // An edge's endpoints reach each other through it, so checking one // endpoint decides the whole edge. Retracted edges (count == 0) - // are no longer facts at all, so they're excluded rather than - // reported as unreachable ones. + // are no longer facts at all, and hidden-label edges are invisible + // to the audit, so both are excluded rather than reported as + // unreachable ones. let mut out = Vec::new(); for edge_id in 0..self.edges.len() as u32 { if deadline.expired() { return Err(DeadlineExceeded); } let edge = &self.edges[edge_id as usize]; - if edge.count > 0 && !visited.contains(&edge.subject) { + if edge.count > 0 && visible(edge.label) && !visited.contains(&edge.subject) { out.push(self.association(edge_id)); } } diff --git a/src/llm-protocol.md b/src/llm-protocol.md index d3d72357..b2c04fcc 100644 --- a/src/llm-protocol.md +++ b/src/llm-protocol.md @@ -293,7 +293,7 @@ Source code takes the same discipline; only the naming changes. | Method | Path | Body / returns | |---|---|---| -| GET | `/contexts` | `?limit=1000&after=name` → `{total, contexts:[{name, description, pinned, loaded, dice_floor, semantic_floor, stats, usage, revision}]}` (keyset paging by name; `revision` = change counters `{graph, passages, config}` — graph writes, passage writes, and config/embedding changes respectively; equal counters ⇒ that lane's answers are unchanged since you last looked, so a cache can key on them — compare for EQUALITY only, and re-check after a server restart: a crash can lag a cold context's counters until its first load, and delete-recreate restarts them; the server itself already runs an exact-match result cache keyed this way, so repeating an identical recall/query/search is cheap without any client-side cache — and, where the operator enabled it, a guarded semantic tier that answers paraphrased passage searches from an equivalent earlier query's entry) | +| GET | `/contexts` | `?limit=1000&after=name` → `{total, contexts:[{name, description, pinned, loaded, dice_floor, semantic_floor, stats, usage, revision, schema_mode}]}` (keyset paging by name; `schema_mode` = `"off"`/`"warn"`/`"strict"` echoed read-only from the installed schema's own `mode` so a client can route without a second `GET /schema` call, `null` for a context that never installed one — never a bare `"off"` standing in for "no document"; `revision` = change counters `{graph, passages, config}` — graph writes, passage writes, and config/embedding changes respectively; equal counters ⇒ that lane's answers are unchanged since you last looked, so a cache can key on them — compare for EQUALITY only, and re-check after a server restart: a crash can lag a cold context's counters until its first load, and delete-recreate restarts them; the server itself already runs an exact-match result cache keyed this way, so repeating an identical recall/query/search is cheap without any client-side cache — and, where the operator enabled it, a guarded semantic tier that answers paraphrased passage searches from an equivalent earlier query's entry) | | GET | `/contexts/{name}` | one directory row / 404 | | PUT | `/contexts/{name}` | `{description?, pinned?, dice_floor?, semantic_floor?}` → create | | PATCH | `/contexts/{name}` | `{description?, pinned?, dice_floor?, semantic_floor?}` → update metadata | @@ -333,6 +333,10 @@ Source code takes the same discipline; only the naming changes. | POST | `/contexts/{name}/communities/search` | `{query, limit?=5, semantic_floor?, derived?}` → `{derived, algorithm, stale, revision:{recorded_graph, current_graph}, plan, hits:[{community, score, text, paragraph, level?, parent?, concept_count?, members?:[{name, strength}], members_truncated?}]}` — global search over a community-summaries artifact built by `taguru communities` (an ordinary context, default `{name}::communities`; `derived` overrides). Ranked by the same two-lane passage search, so `plan`/`semantic_floor` behave exactly as in `sources/search`; `stale: true` = the source graph moved since derivation (summaries describe an older graph, served honestly). No artifact = a refusal naming the build command, never an empty result | | POST | `/contexts/{name}/evidence` | `{origins, labels?, dice_floor?, semantic_floor?, resolve_limit?, activate_decay?, activate_limit?, text_fallback_query?, search_limit?=5, include_communities?=false, budget?:{max_items?=40, max_bytes?=65536, max_tokens?=4000}, rerank?}` → `{items, citations, budget, omitted, omitted_total, omitted_by_reason, plan}` opt-in evidence assembly (ADR 0006): runs the same resolve → query (only when `labels` pins the facets) → activate (always) → search_passages → cite_passage fan-out `retrieve` runs, plus an opt-in community-summary search, then normalizes every result into one ranked (reciprocal-rank fusion), deduplicated, budget-selected package — never the raw per-lane results `retrieve` hands back. `origins`/`labels` share `retrieve`'s own string-or-array contract; the passage/community lanes search `text_fallback_query` when given, otherwise `origins` joined with `"; "`. Each `items[]` entry carries `candidate_id`, `kind` (`association`/`passage`/`community`, an open string), `fused_rank`, `lane_ranks`, `citation_refs` (locators only — the text lives once in the top-level `citations`), `corroboration?` (every independent source an association fact traces to — never collapsed to a count), `contradicts?` (candidate_ids of every association this one disagrees with — a same-`(subject,label)` different-`object`, or opposite-signed, pair is admitted or omitted as one atomic group, never split), `bytes`/`estimated_tokens`, and exactly one of `association`/`passage`/`community` (the existing wire shape, embedded verbatim). `budget` is three independent hard ceilings — reaching any one stops admission; an over-budget candidate is skipped, not a call-ending refusal, so even `max_items: 0` answers 200 with an empty package and every candidate named under `omitted` (capped like `Issue` lists) or counted in the uncapped `omitted_total`/`omitted_by_reason`. `plan.lanes` is one `{ran, reason?, floor?}` per fan-out lane (`resolve`, `query`, `activate`, `passages`, `communities`, `citations` — the same shape `sources/search`'s own `plan.contexts[].lanes` uses); `include_communities: true` without a derived-communities artifact is a *degrade* here (`plan.lanes.communities.ran: false`), never the refusal `communities/search` itself gives — community evidence is one opt-in input among several, not the entire point of this call. `plan.selection` reports dedup/contradiction-group/diversity-tier accounting; `rerank?: {model?}` opts into reordering the already-deduplicated, near-duplicate-suppressed pool through an optional reranker (ADR 0006 §12, #307) — absent `rerank`, or no `TAGURU_RERANK_URL`/`TAGURU_RERANK_MODEL` configured on this server, keeps `plan.reranker = {configured, ran: false}` and selection fully deterministic, at no network or credential cost. When configured and requested, `plan.reranker = {configured: true, ran: true, model}` on success, or `{configured: true, ran: false, reason}` on any degrade — a model mismatch, an empty/singleton pool, an open circuit, a timeout, a provider error, or a non-permutation response — where `reason` is one of the machine-readable tokens `not_configured`/`model_mismatch`/`empty_pool`/`circuit_open`/`timeout`/`provider_error`/`invalid_permutation`. A reranker may only reorder the pool it is handed — it can never add, drop, or edit a candidate — and every degrade falls back to the same deterministic reciprocal-rank-fusion order, still answered 200, never a call-ending refusal. Candidate text reaches a configured reranker provider and nowhere else — never a log line, an error message, or a metric label. Never changes `retrieve`'s or any direct endpoint's own behavior | | POST | `/contexts/{name}/vocabulary/audit` | `{dice_floor?=0.6, cosine_floor?=0.6}` → spelling/synonym fork candidates | +| POST | `/contexts/{name}/drift/audit` | `{unsourced_floor?, limit?, after?, include_twins?=false, dice_floor?=0.6, cosine_floor?=0.6}` → `{total, unsourced:[{unsourced_weight, unsourced_count, association}], dead_concept_aliases, dead_label_aliases, twins?}` graph-vs-archive drift: edges carrying weight no named source explains (worst magnitude first, paged like `unreachable_from`), aliases whose canonical no longer lives, and — only when `include_twins` — the same fork candidates `vocabulary/audit` finds | +| GET/PUT | `/contexts/{name}/schema` | GET → the installed schema document `{schema:1, mode, closed_labels, types, relations}` / 404 `no_schema` (distinct from `no_context`) when none is installed; PUT installs (or replaces) it, answering the document as installed — refused (400) for a document whose `relations` declare the reserved `schema:type` label, or when an already-persisted label alias resolves to it (rename the alias first). Installing changes what `strict` refuses from this point on; dry-run with `schema/validate` first | +| POST | `/contexts/{name}/schema/audit` | `{limit?, after?}` (body optional) → `{total, violations:[{association, issues}], untyped_concepts:{total, names}, undeclared_types:{total, names}, unknown_labels:{total, names}, reserved_alias_conflicts:{total, aliases}}` — judges every LIVE association against the installed document, the pre-existing violations `strict` can never surface on its own; candidates for review, never auto-fixed; only `violations` pages (`total` constant across pages, same cursor as recall/query); 404 `no_schema` without an installed document | +| POST | `/contexts/{name}/schema/validate` | `{document, limit?, after?}` → the same audit shape over the PROPOSED document, validated and evaluated without ever being persisted — the pre-flight before a `strict` flip; works identically with or without an installed schema | | GET | `/contexts/{name}/export` | the context as an import batch stream (JSON Lines body, not the JSON envelope) — one batch per source, create block first, aliases last; `POST /import` (or `taguru import`) restores it, per-source retract-then-apply, answering `{batches: [...]}` in stream order (`taguru_group` records ride the same stream, restore after every batch as whole-record replaces, and answer under `groups: [...]`) | | POST | `/contexts/{name}/compact` | rebuild the image without dead records (admin; the context's requests wait out the rebuild) → `{bytes_before, bytes_after, dead_edges, aliases_dropped}` | | POST | `/maintenance/compact` | `?min_dead_ratio=0.0` (default; any dead weight at all) → sweep every context whose live dead ratio strictly exceeds it, worst ratio first, each rebuilt like `/contexts/{name}/compact`; admin, server-wide (refused for a context-scoped key, like `/flush`) — closes the server to ordinary traffic for the sweep (`/health` answers `503 maintenance` meanwhile, distinct from an actual fault) and reopens when it ends or the deadline cuts it short → `{contexts:[{name, bytes_before, bytes_after, dead_edges, aliases_dropped}], deadline_exceeded}` | @@ -390,7 +394,9 @@ Content-Type, mistyped shape) / `invalid_argument` (parsed, but a value was refused: empty or oversized name, bad weight, bad cursor) / `over_limit` (a batch or list over its per-request cap — split and resend) / `unauthorized` / `forbidden` / `no_context` / `no_source` / -`no_paragraph` / `no_group` / `unknown_path` / `method_not_allowed` / `timeout` / +`no_paragraph` / `no_group` / `no_schema` (404: the context exists but +has no schema document installed — distinct from `no_context`) / +`unknown_path` / `method_not_allowed` / `timeout` / `already_exists` / `conflict` / `payload_too_large` / `rate_limited` / `internal` / `embeddings_unconfigured` / `embeddings_failed` / `overloaded` (shed at the global in-flight ceiling or the shared diff --git a/tests/http_api/schema_type_label.rs b/tests/http_api/schema_type_label.rs index bc471381..13afab71 100644 --- a/tests/http_api/schema_type_label.rs +++ b/tests/http_api/schema_type_label.rs @@ -2,8 +2,9 @@ //! exclusions (#381, S3 of #218's ADR 0009 split, §6.3): inert until a //! schema document exists for the context (guard 1), an alias refusal //! that fires in every mode including `off` once one does (guard 2's -//! `add_label_alias` bullet), and the three exclusions the label's -//! representation cost requires — traversal, the default label page, +//! `add_label_alias` bullet), and the exclusions the label's +//! representation cost requires — traversal (`explore`/`activate` and +//! the `unreachable_from` coverage audit), the default label page, //! and the vocabulary twin audit. `PUT /schema`'s own refusals (guard //! 3, guard 2's migration-boundary bullet) live in `schema.rs`; the //! domain/range judgment itself (`schema_issues`) has no write entrance @@ -220,6 +221,63 @@ fn activate_never_propagates_through_schema_type_once_a_schema_exists() { ); } +/// The coverage audit gets the same traversal exclusion as +/// `explore`/`activate`: once a schema exists, a `schema:type` edge to a +/// shared type name must not bridge otherwise-disconnected facts in +/// `unreachable_from`'s reachability walk — that hub would silently +/// under-report genuine orphans, the one failure mode a coverage audit +/// exists to catch. The hidden edges themselves are also never reported +/// as orphans (`explore_excluding`'s "never reported, never a bridge" +/// contract), so the audit sees exactly the non-typing fact graph. +#[test] +fn unreachable_from_never_travels_through_schema_type_once_a_schema_exists() { + let server = Server::start("schema-type-label-coverage"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "蔵", "label": "銘柄", "object": "青嶺", + "weight": 1.0, "source": "a.md"}, + // An island fact, connected to the origin cluster only + // through the two schema:type edges' shared type object. + {"subject": "孤島", "label": "l", "object": "先", + "weight": 1.0, "source": "a.md"}, + {"subject": "蔵", "label": "schema:type", "object": "Brewery", + "weight": 1.0, "source": "a.md"}, + {"subject": "孤島", "label": "schema:type", "object": "Brewery", + "weight": 1.0, "source": "a.md"}, + ])), + ); + + let audit = |server: &Server| { + server.ok( + "POST", + "/contexts/sake/unreachable_from", + Some(json!({"origins": ["蔵"]})), + ) + }; + + let before = audit(&server); + assert_eq!( + before["total"], + json!(0), + "before a schema exists, schema:type bridges like any other label and the island \ + counts as covered: {before}" + ); + + server.ok("PUT", "/contexts/sake/schema", Some(off_document())); + + let after = audit(&server); + assert_eq!( + after["total"], + json!(1), + "once a schema exists, the type hub no longer bridges — the island is a genuine \ + orphan, and the hidden schema:type edges themselves are not reported: {after}" + ); + assert_eq!(after["matches"][0]["subject"], json!("孤島"), "{after}"); +} + /// Guard 2's refusal must fire against a fresh alias in `warn`/`strict` /// too, not only `off` — the gate is document existence, not mode. #[test] From 0d679bef0e1a6610e3ceee49c69eb2d37e1515e9 Mon Sep 17 00:00:00 2001 From: Takashi Yamashina Date: Tue, 4 Aug 2026 20:08:00 +0900 Subject: [PATCH 2/3] schema: address CodeRabbit review on #403 - Pin the reported orphan's whole edge (label/object, not subject alone) in the coverage-audit exclusion test. - Verify audit_schema/validate_schema's `after` cursor reaches the wire verbatim in both SDK unit suites. - Batched warn-carrier aggregation now answers DISTINCT per-chunk issues/counts (order + sum visible), and the TypeScript suite gains the batched warn test it lacked. - get_schema's doc now points at the no_schema/no_context error codes instead of claiming a second request is needed; sync regenerated. Skipped the coverage.rs is_empty() branch nitpick: explore/activate pass a possibly-empty exclusion list unconditionally, and diverging in one handler would trade a per-edge empty-HashSet probe for an inconsistency with the established pattern. Claude-Session: https://claude.ai/code/session_01HqB7fXgCKSnDxT58PenLaS --- sdk/python/src/taguru/_async/client.py | 10 +++--- sdk/python/src/taguru/_sync/client.py | 10 +++--- sdk/python/tests/unit/test_get_schema.py | 13 ++++--- .../unit/test_pagination_and_batching.py | 33 ++++++++++++++--- sdk/typescript/tests/unit/get-schema.test.ts | 16 ++++++--- sdk/typescript/tests/unit/transport.test.ts | 35 +++++++++++++++++++ tests/http_api/schema_type_label.rs | 2 ++ 7 files changed, 97 insertions(+), 22 deletions(-) diff --git a/sdk/python/src/taguru/_async/client.py b/sdk/python/src/taguru/_async/client.py index 3b7fee2a..8cd5cacd 100644 --- a/sdk/python/src/taguru/_async/client.py +++ b/sdk/python/src/taguru/_async/client.py @@ -1007,11 +1007,11 @@ async def get_schema(self) -> SchemaDocument: `POST /contexts/{name}/schema/audit`, `taguru extract --schema`, and both LangChain ingesters read. - Raises ``NotFoundError`` when the context has no schema - installed (or does not exist) — the same 404 - ``list_labels``/friends already give, so a caller distinguishes - the two only by checking `list`/`get` on the context itself - first, if it needs to. + Raises ``NotFoundError`` with ``code == "no_schema"`` when the + context exists but has no schema installed, or ``code == + "no_context"`` when the context itself does not exist — inspect + the error's ``code`` to tell the two apart without a second + request. """ result = await self._client._request_json("GET", self._path + "/schema") return decode(SchemaDocument, result) # type: ignore[no-any-return] diff --git a/sdk/python/src/taguru/_sync/client.py b/sdk/python/src/taguru/_sync/client.py index cbbd47fa..2dd40d37 100644 --- a/sdk/python/src/taguru/_sync/client.py +++ b/sdk/python/src/taguru/_sync/client.py @@ -993,11 +993,11 @@ def get_schema(self) -> SchemaDocument: `POST /contexts/{name}/schema/audit`, `taguru extract --schema`, and both LangChain ingesters read. - Raises ``NotFoundError`` when the context has no schema - installed (or does not exist) — the same 404 - ``list_labels``/friends already give, so a caller distinguishes - the two only by checking `list`/`get` on the context itself - first, if it needs to. + Raises ``NotFoundError`` with ``code == "no_schema"`` when the + context exists but has no schema installed, or ``code == + "no_context"`` when the context itself does not exist — inspect + the error's ``code`` to tell the two apart without a second + request. """ result = self._client._request_json("GET", self._path + "/schema") return decode(SchemaDocument, result) # type: ignore[no-any-return] diff --git a/sdk/python/tests/unit/test_get_schema.py b/sdk/python/tests/unit/test_get_schema.py index 7318540e..bb6fdaba 100644 --- a/sdk/python/tests/unit/test_get_schema.py +++ b/sdk/python/tests/unit/test_get_schema.py @@ -122,10 +122,15 @@ def handler(req: httpx.Request) -> httpx.Response: seen["body"] = json.loads(req.content) return ok_response(SCHEMA_AUDIT) + # A non-trivial cursor must reach the wire verbatim — `violations` + # pages exactly like recall/query, so a dropped or reshaped `after` + # would silently restart every page from the top. + cursor = {"weight": 1.0, "subject": "青嶺酒造", "label": "杜氏", "object": "広島"} + client = sync_client(handler) - audit = client.context("aomine").audit_schema(limit=10) + audit = client.context("aomine").audit_schema(limit=10, after=cursor) assert seen["path"] == "/contexts/aomine/schema/audit" - assert seen["body"] == {"limit": 10} + assert seen["body"] == {"limit": 10, "after": cursor} assert isinstance(audit, SchemaAudit) assert audit.total == 1 violation = audit.violations[0] @@ -134,7 +139,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert audit.untyped_concepts.names == ["広島", "青嶺"] assert audit.reserved_alias_conflicts.aliases == {"種類": "schema:type"} - validated = client.context("aomine").validate_schema(SCHEMA_DOCUMENT, limit=10) + validated = client.context("aomine").validate_schema(SCHEMA_DOCUMENT, limit=10, after=cursor) assert seen["path"] == "/contexts/aomine/schema/validate" - assert seen["body"] == {"document": SCHEMA_DOCUMENT, "limit": 10} + assert seen["body"] == {"document": SCHEMA_DOCUMENT, "limit": 10, "after": cursor} assert validated.total == 1 diff --git a/sdk/python/tests/unit/test_pagination_and_batching.py b/sdk/python/tests/unit/test_pagination_and_batching.py index b658dbdb..f8b6b6e0 100644 --- a/sdk/python/tests/unit/test_pagination_and_batching.py +++ b/sdk/python/tests/unit/test_pagination_and_batching.py @@ -190,7 +190,32 @@ def handler(req: httpx.Request) -> httpx.Response: (issue["path"], issue["kind"], issue["expected"], issue["actual"]) ] - # Batched aggregation carries the same fields through, in chunk order. - batched = client.context("sake").add_associations_batched([_op(0), _op(1)], chunk_size=1) - assert batched.schema_violations == 6 - assert len(batched.issues) == 2 + # Batched aggregation preserves chunk order and sums the true counts — + # each chunk answers with DISTINCT values, so a reordering or a + # double-count would be visible, not coincidentally equal. + chunks_seen = 0 + + def chunked_handler(req: httpx.Request) -> httpx.Response: + nonlocal chunks_seen + chunks_seen += 1 + return httpx.Response( + 200, + json={ + "result": 1, + "status": "ok", + "time": 0.001, + "issues": [dict(issue, path=f"associations[0].chunk{chunks_seen}")], + "schema_violations": chunks_seen, + }, + ) + + batched_client = sync_client(chunked_handler) + batched = batched_client.context("sake").add_associations_batched( + [_op(0), _op(1)], chunk_size=1 + ) + assert batched.applied == 2 + assert batched.schema_violations == 1 + 2 + assert [i.path for i in batched.issues] == [ + "associations[0].chunk1", + "associations[0].chunk2", + ] diff --git a/sdk/typescript/tests/unit/get-schema.test.ts b/sdk/typescript/tests/unit/get-schema.test.ts index ce179477..09e35f33 100644 --- a/sdk/typescript/tests/unit/get-schema.test.ts +++ b/sdk/typescript/tests/unit/get-schema.test.ts @@ -98,10 +98,14 @@ describe("putSchema / auditSchema / validateSchema", () => { requests.push(req); return okBody(SCHEMA_AUDIT); }); + // A non-trivial cursor must reach the wire verbatim — `violations` + // pages exactly like recall/query, so a dropped or reshaped `after` + // would silently restart every page from the top. + const cursor = { weight: 1.0, subject: "青嶺酒造", label: "杜氏", object: "広島" }; - const audit = await client.context("aomine").auditSchema({ limit: 10 }); + const audit = await client.context("aomine").auditSchema({ limit: 10, after: cursor }); expect(requests[0]?.path).toBe("/contexts/aomine/schema/audit"); - expect(JSON.parse(requests[0]?.body ?? "")).toEqual({ limit: 10 }); + expect(JSON.parse(requests[0]?.body ?? "")).toEqual({ limit: 10, after: cursor }); expect(audit.total).toBe(1); expect(audit.violations[0]?.association.object).toBe("広島"); expect(audit.violations[0]?.issues[0]?.kind).toBe("range"); @@ -109,9 +113,13 @@ describe("putSchema / auditSchema / validateSchema", () => { const validated = await client .context("aomine") - .validateSchema(SCHEMA_DOCUMENT, { limit: 10 }); + .validateSchema(SCHEMA_DOCUMENT, { limit: 10, after: cursor }); expect(requests[1]?.path).toBe("/contexts/aomine/schema/validate"); - expect(JSON.parse(requests[1]?.body ?? "")).toEqual({ document: SCHEMA_DOCUMENT, limit: 10 }); + expect(JSON.parse(requests[1]?.body ?? "")).toEqual({ + document: SCHEMA_DOCUMENT, + limit: 10, + after: cursor, + }); expect(validated.total).toBe(1); }); }); diff --git a/sdk/typescript/tests/unit/transport.test.ts b/sdk/typescript/tests/unit/transport.test.ts index 78e94f0f..b0b2da62 100644 --- a/sdk/typescript/tests/unit/transport.test.ts +++ b/sdk/typescript/tests/unit/transport.test.ts @@ -402,6 +402,41 @@ describe("batching", () => { const result = await client.context("sake").addAssociations([op(0)]); expect(result).toEqual({ applied: 1, issues: [issue], schema_violations: 3 }); }); + + it("addAssociationsBatched aggregates the warn-mode carrier in chunk order", async () => { + // Each chunk answers with DISTINCT values, so a reordering or a + // double-count would be visible, not coincidentally equal. + let chunksSeen = 0; + const issueFor = (chunk: number) => ({ + path: `associations[0].chunk${chunk}`, + kind: "range", + expected: "one of [Brewery]", + actual: "Prefecture", + }); + const client = stubClient(() => { + chunksSeen += 1; + return { + status: 200, + headers: {}, + body: JSON.stringify({ + result: 1, + status: "ok", + time: 0.001, + issues: [issueFor(chunksSeen)], + schema_violations: chunksSeen, + }), + }; + }); + const result = await client + .context("sake") + .addAssociationsBatched([op(0), op(1)], { chunk_size: 1 }); + expect(result).toEqual({ + applied: 2, + chunks: 2, + issues: [issueFor(1), issueFor(2)], + schema_violations: 1 + 2, + }); + }); }); describe("retrieve loop", () => { diff --git a/tests/http_api/schema_type_label.rs b/tests/http_api/schema_type_label.rs index 13afab71..fb8bda09 100644 --- a/tests/http_api/schema_type_label.rs +++ b/tests/http_api/schema_type_label.rs @@ -276,6 +276,8 @@ fn unreachable_from_never_travels_through_schema_type_once_a_schema_exists() { orphan, and the hidden schema:type edges themselves are not reported: {after}" ); assert_eq!(after["matches"][0]["subject"], json!("孤島"), "{after}"); + assert_eq!(after["matches"][0]["label"], json!("l"), "{after}"); + assert_eq!(after["matches"][0]["object"], json!("先"), "{after}"); } /// Guard 2's refusal must fire against a fresh alias in `warn`/`strict` From 9b647d9c393dfa4974e9930301f08a3797cfffe7 Mon Sep 17 00:00:00 2001 From: Takashi Yamashina Date: Tue, 4 Aug 2026 20:15:31 +0900 Subject: [PATCH 3/3] schema: address CodeRabbit round 2 on #403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched warn-carrier stubs now derive each response from the chunk the request actually carried (parsed from the body's op subject), not from handler call order — a reordered transmission, a dropped chunk, or a double-count are all now distinguishable. Applied to the Python twin too, which had the same call-order weakness the review caught on the TypeScript side. Claude-Session: https://claude.ai/code/session_01HqB7fXgCKSnDxT58PenLaS --- .../unit/test_pagination_and_batching.py | 23 +++++++++--------- sdk/typescript/tests/unit/transport.test.ts | 24 ++++++++++--------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/sdk/python/tests/unit/test_pagination_and_batching.py b/sdk/python/tests/unit/test_pagination_and_batching.py index f8b6b6e0..4444cdc4 100644 --- a/sdk/python/tests/unit/test_pagination_and_batching.py +++ b/sdk/python/tests/unit/test_pagination_and_batching.py @@ -190,22 +190,23 @@ def handler(req: httpx.Request) -> httpx.Response: (issue["path"], issue["kind"], issue["expected"], issue["actual"]) ] - # Batched aggregation preserves chunk order and sums the true counts — - # each chunk answers with DISTINCT values, so a reordering or a - # double-count would be visible, not coincidentally equal. - chunks_seen = 0 + # Batched aggregation preserves chunk order and sums the true counts. + # Each response is derived from the CHUNK THE REQUEST CARRIED, not + # from handler call order — so a reordered transmission, a dropped + # chunk, or a double-count would all be visible. + violations_for = {"s0": 1, "s1": 2} def chunked_handler(req: httpx.Request) -> httpx.Response: - nonlocal chunks_seen - chunks_seen += 1 + ops = json.loads(req.content) + subject = ops[0]["subject"] return httpx.Response( 200, json={ - "result": 1, + "result": len(ops), "status": "ok", "time": 0.001, - "issues": [dict(issue, path=f"associations[0].chunk{chunks_seen}")], - "schema_violations": chunks_seen, + "issues": [dict(issue, path=f"associations[0].{subject}")], + "schema_violations": violations_for[subject], }, ) @@ -216,6 +217,6 @@ def chunked_handler(req: httpx.Request) -> httpx.Response: assert batched.applied == 2 assert batched.schema_violations == 1 + 2 assert [i.path for i in batched.issues] == [ - "associations[0].chunk1", - "associations[0].chunk2", + "associations[0].s0", + "associations[0].s1", ] diff --git a/sdk/typescript/tests/unit/transport.test.ts b/sdk/typescript/tests/unit/transport.test.ts index b0b2da62..ebcd2a28 100644 --- a/sdk/typescript/tests/unit/transport.test.ts +++ b/sdk/typescript/tests/unit/transport.test.ts @@ -404,26 +404,28 @@ describe("batching", () => { }); it("addAssociationsBatched aggregates the warn-mode carrier in chunk order", async () => { - // Each chunk answers with DISTINCT values, so a reordering or a - // double-count would be visible, not coincidentally equal. - let chunksSeen = 0; - const issueFor = (chunk: number) => ({ - path: `associations[0].chunk${chunk}`, + // Each response is derived from the CHUNK THE REQUEST CARRIED, not + // from handler call order — so a reordered transmission, a dropped + // chunk, or a double-count would all be visible. + const issueFor = (subject: string) => ({ + path: `associations[0].${subject}`, kind: "range", expected: "one of [Brewery]", actual: "Prefecture", }); - const client = stubClient(() => { - chunksSeen += 1; + const violationsFor: Record = { s0: 1, s1: 2 }; + const client = stubClient((req) => { + const ops = JSON.parse(req.body ?? "[]") as { subject: string }[]; + const subject = ops[0]?.subject ?? "unknown"; return { status: 200, headers: {}, body: JSON.stringify({ - result: 1, + result: ops.length, status: "ok", time: 0.001, - issues: [issueFor(chunksSeen)], - schema_violations: chunksSeen, + issues: [issueFor(subject)], + schema_violations: violationsFor[subject] ?? 0, }), }; }); @@ -433,7 +435,7 @@ describe("batching", () => { expect(result).toEqual({ applied: 2, chunks: 2, - issues: [issueFor(1), issueFor(2)], + issues: [issueFor("s0"), issueFor("s1")], schema_violations: 1 + 2, }); });