diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e8fce83..0f35a9ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,32 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] -### Added +- Post-content recovery now keys every initial attempt, retry, and stale lease + by its exact eligibility instant, so work that becomes due after the durable + cursor advances is reached without waiting for a full ledger wrap. + +- Global Ask public verification now admits only bounded, provenance-bearing + persisted claims attached to exact cited public posts; missing admission + fails closed without token-overlap egress. + +### Added + +- Temporal topic influence now has a durable external-production path: the + worker binds the exact completed TEPP artifact, posterior draws, and + business-unit/PU/team/person memberships into a content-addressed request, + then persists only a complete, converged, identified, parity-passed + fast-mlsirm result. Missing owner transport, partial rows, or digest mismatch + remains unavailable without local scoring. Time-valid membership slices + remain distinct; incomplete evidence enters an event-woken awaiting state; + expired work is reclaimed only from its declared request/lease contract, + whose lease must strictly exceed the request timeout for persistence; and + every terminal transition matches a unique lease token. Evidence changed + during computation releases a fresh request automatically. + LineageWeave-owned request and membership bytes and producer-owned result + bytes are SHA-256 verified before parsing, so admission never depends on + cross-language JSON reserialization. Other + retries use only an exact remote delay or an explicit operator requeue + (ADR 0210). - Evidence Operations now presents cited claim, rebid, handover, external, product, and Voice evidence with explicit unavailable states and source-open @@ -224,6 +249,13 @@ All notable changes to this project are documented here. Format follows ### Fixed +- Public-claim provenance validation now selects its sole UUID binding without + calling PostgreSQL's unavailable `min(uuid)` aggregate, so fresh schema + replays accept valid provenance-bound public claims. +- Post-content wake-up recovery now advances through every ready ledger page + with a deterministic keyset, while Valkey trims only entries already + consumed by the worker. Large backfills can no longer replay the same first + page until a later queued record starves or its unread wake-up is trimmed. - Full-corpus Event Lineage rebuilds now count candidate pairs before provider work and omit the optional LLM channel above the 5,000-pair ADR budget, preventing millions of synchronous orchestrator calls while retaining one diff --git a/backend/app/config.py b/backend/app/config.py index a4cf71b24..325600069 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,6 +62,11 @@ class Settings: source_research_maximum_results: int | None tepp_transport_url: str tepp_api_key: str + topic_influence_transport_url: str + topic_influence_api_key: str + topic_influence_request_timeout_seconds: int | None + topic_influence_lease_timeout_seconds: int | None + topic_influence_poll_seconds: int | None caldav_base_url: str naruon_calendar_base_url: str naruon_calendar_service_token: str @@ -215,6 +220,21 @@ def load_settings() -> Settings: ), tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""), tepp_api_key=os.environ.get("TEPP_API_KEY", "").strip(), + topic_influence_transport_url=os.environ.get( + "TOPIC_INFLUENCE_TRANSPORT_URL", "" + ).strip(), + topic_influence_api_key=os.environ.get( + "TOPIC_INFLUENCE_API_KEY", "" + ).strip(), + topic_influence_request_timeout_seconds=_optional_positive_int( + "TOPIC_INFLUENCE_REQUEST_TIMEOUT_SECONDS" + ), + topic_influence_lease_timeout_seconds=_optional_positive_int( + "TOPIC_INFLUENCE_LEASE_TIMEOUT_SECONDS" + ), + topic_influence_poll_seconds=_optional_positive_int( + "TOPIC_INFLUENCE_POLL_SECONDS" + ), caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(), naruon_calendar_base_url=os.environ.get("NARUON_CALENDAR_BASE_URL", "").strip(), naruon_calendar_service_token=os.environ.get( diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 9a32cd5d7..83f2fa065 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -38,7 +38,6 @@ ClaimVerificationClient, ClaimVerificationResult, NullClaimVerificationClient, - public_claim_candidates, ) from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.http_client import HttpClientError @@ -52,6 +51,10 @@ cited_post_summaries, historical_body_limitations, ) +from lineageweave.public_claim_envelope import ( + PersistedPublicClaimEnvelope, + envelope_from_authorized_row, +) from lineageweave.semantic_query import NullSemanticQueryClient, SemanticQueryClient from lineageweave.temporal_expressions import resolve_korean_relative_time @@ -103,6 +106,33 @@ _logger = logging.getLogger(__name__) +_AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL = """ + select envelope.public_claim_envelope_id, + envelope.source_post_id, + envelope.claim_kind_code, + envelope.claim_text + from public_claim_envelope envelope + join source_post post on post.post_id = envelope.source_post_id + join provenance_assertion assertion + on assertion.assertion_id = envelope.provenance_assertion_id + and assertion.relation_code = 'prov_was_derived_from' + where envelope.egress_eligible + and post.visibility_code = 'public' + and envelope.source_post_id = any($1::uuid[]) + and exists ( + select 1 + from provenance_resource_binding evidence + where evidence.resource_id = assertion.object_resource_id + and evidence.node_type_code = 'node_post' + and evidence.node_id = envelope.source_post_id + ) + and ($2::timestamptz is null or ( + envelope.created_at <= $2 and post.created_at <= $2 + )) + order by envelope.created_at, envelope.public_claim_envelope_id + limit 4 +""" + class _SafeJobError(Exception): """Failure whose bounded message is safe to persist for the requester.""" @@ -188,16 +218,20 @@ async def _verify_public_claims( *, verify_external: bool, client: ClaimVerificationClient, + persisted_envelopes: tuple[PersistedPublicClaimEnvelope, ...] = (), ) -> tuple[str, tuple[ClaimVerificationResult, ...]]: - """Verify only cited claims explicitly marked safe for public egress.""" + """Verify only cited claims explicitly marked safe for public egress. + + Only persisted admission envelopes may cross the public verifier. Omitting + them fails closed; question-token overlap is not an admission mechanism. + """ if not verify_external: return VERIFICATION_SKIPPED, () cited_ids = frozenset(cited_post_ids) + claims = tuple(envelope.verification_candidate() for envelope in persisted_envelopes) claims = tuple( - claim - for claim in public_claim_candidates(sources, question) - if set(claim.source_post_ids).issubset(cited_ids) + claim for claim in claims if set(claim.source_post_ids).issubset(cited_ids) ) if not claims: return VERIFICATION_NO_PUBLIC_CLAIMS, () @@ -219,6 +253,28 @@ async def _verify_public_claims( ) +async def load_authorized_public_claim_envelopes( + conn: asyncpg.Connection, + cited_post_ids: list[str], + *, + knowledge_cutoff: datetime | None, +) -> tuple[PersistedPublicClaimEnvelope, ...]: + """Load bounded persisted claims for exact cited public evidence posts.""" + + if not cited_post_ids: + return () + rows = await conn.fetch( + _AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL, + cited_post_ids, + knowledge_cutoff, + ) + return tuple( + envelope + for row in rows + if (envelope := envelope_from_authorized_row(row)) is not None + ) + + async def load_job_visibility( conn: asyncpg.Connection, job_id: str, account_id: str ) -> tuple[set[str], set[str], bool, bool]: @@ -372,6 +428,7 @@ def can_see(row: asyncpg.Record) -> bool: [], verify_external=verify_external, client=verification_client, + persisted_envelopes=(), ) delivery = build_ask_delivery("", (), ()) return { @@ -433,14 +490,16 @@ def can_see(row: asyncpg.Record) -> bool: _ASK_RETRY_MESSAGE, ) from exc cited_ids = list(answer.cited_post_ids) - verification_status, external_claims = await _verify_public_claims( - question_text, - usable_sources, - cited_ids, - verify_external=verify_external, - client=verification_client, - ) async with pool.acquire() as conn: + persisted_envelopes = ( + await load_authorized_public_claim_envelopes( + conn, + cited_ids, + knowledge_cutoff=knowledge_cutoff, + ) + if verify_external + else () + ) if knowledge_cutoff is None: lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) images = await cited_post_images(conn, cited_ids) @@ -452,6 +511,14 @@ def can_see(row: asyncpg.Record) -> bool: cited_ids, checked_by=knowledge_cutoff, ) + verification_status, external_claims = await _verify_public_claims( + question_text, + usable_sources, + cited_ids, + verify_external=verify_external, + client=verification_client, + persisted_envelopes=persisted_envelopes, + ) cited_posts = cited_post_summaries(usable_sources, cited_ids) cited_events = cited_post_events(usable_sources, cited_ids) cited_evidence = cited_post_evidence(usable_sources, cited_ids) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 9d9db2d59..33e6e78d1 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -189,14 +189,6 @@ async def fetch_operations_dashboard( ) ) select (select count(*) from visible_post) as total_post_count, - (select count(*) - from post_summary_event summary_event - where exists ( - select 1 from classified - where classified.post_id = summary_event.post_id - and ($5::boolean is false - or classified.case_kind_code = 'external_information') - )) as total_event_count, (select count(distinct post_id) from classified where case_kind_code = 'external_information') as external_post_count, (select count(*) from scoped_post @@ -225,10 +217,7 @@ async def fetch_operations_dashboard( coalesce(post.event_occurred_at, post.created_at) as occurred_at, coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name) as project_name, - coalesce(project.project_names, array[]::text[]) as project_names, - (select count(*)::int - from post_summary_event summary_event - where summary_event.post_id = classification.post_id) as event_count + coalesce(project.project_names, array[]::text[]) as project_names from operations_case_classification classification join source_post post on post.post_id = classification.post_id join source_post evidence_post @@ -439,10 +428,17 @@ async def fetch_operations_dashboard( external = int(metrics["external_post_count"]) case_post_ids: dict[str, set[str]] = {} case_event_counts: dict[str, int] = {} + counted_case_keys: set[tuple[str, str]] = set() for row in case_rows: kind = row["case_kind_code"] - case_post_ids.setdefault(kind, set()).add(str(row["post_id"])) - case_event_counts[kind] = case_event_counts.get(kind, 0) + int(row["event_count"]) + post_id = str(row["post_id"]) + case_post_ids.setdefault(kind, set()).add(post_id) + key = (post_id, kind) + if key not in counted_case_keys: + case_event_counts[kind] = case_event_counts.get(kind, 0) + len( + milestones.get(key, ()) + ) + counted_case_keys.add(key) projected_cases = [] lifecycle_metrics = { lifecycle_code: { @@ -493,7 +489,7 @@ async def fetch_operations_dashboard( "period_end": period_end.isoformat() if period_end else None, "period_time_axis_code": "event_occurred_at", "total_post_count": total, - "total_event_count": int(metrics["total_event_count"]), + "total_event_count": sum(case_event_counts.values()), "external_post_count": external, "external_percent": external * 100 / total if total else 0.0, "pending_analysis_count": int(metrics["pending_analysis_count"]), diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index cec4b5d28..bd089accd 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -3,8 +3,8 @@ from __future__ import annotations import hashlib -from datetime import timedelta from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any import asyncpg @@ -34,6 +34,15 @@ class PostContentJobRequest: should_publish: bool +@dataclass(frozen=True) +class PostContentRecoveryPage: + """One fair recovery page and the keyset needed for the next page.""" + + published_count: int + next_eligible_at: datetime | None + next_post_id: str | None + + def source_body_sha256(body: str) -> str: """Hash the immutable source representation, never the derived content.""" return hashlib.sha256(body.encode("utf-8")).hexdigest() @@ -152,6 +161,17 @@ async def publish_post_content_event( return str(entry_id) +async def trim_post_content_events_through(client: redis.Redis, entry_id: str) -> None: + """Trim only wake-ups at or before the worker's consumed cursor.""" + milliseconds, sequence = entry_id.split("-", 1) + exclusive_minimum = f"{int(milliseconds)}-{int(sequence) + 1}" + await client.xtrim( + POST_CONTENT_STREAM_KEY, + minid=exclusive_minimum, + approximate=False, + ) + + async def _record_status( conn: asyncpg.Connection, post_id: str, @@ -384,20 +404,13 @@ async def ensure_post_content_job( POST_CONTENT_BACKFILL_CANDIDATE_SQL = f""" select post.post_id, post.post_body from source_post post - left join post_content_ingestion_job job on job.post_id = post.post_id - left join operations_case_analysis analysis - on analysis.post_id = post.post_id - and analysis.source_body_sha256 = job.source_body_sha256 - left join post_product_analysis product_analysis - on product_analysis.post_id = post.post_id - and product_analysis.source_body_sha256 = job.source_body_sha256 - left join ( - select distinct project.post_id - from post_project_mention project - where nullif(btrim(project.ontology_iri), '') is not null - ) ontology_project on ontology_project.post_id = post.post_id where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} - and (job.post_id is null or job.status_code = $1) + and not exists ( + select 1 + from post_content_ingestion_job job + where job.post_id = post.post_id + and job.status_code is distinct from $1 + ) and ( not exists ( select 1 from post_content_unit unit @@ -436,14 +449,45 @@ async def ensure_post_content_job( or structure.decision_source_code = 'unresolved' ) )) - or ($3::boolean and analysis.post_id is null) - or ($3::boolean and product_analysis.post_id is null) + or ($3::boolean and not exists ( + select 1 + from post_content_ingestion_job job + join operations_case_analysis analysis + on analysis.post_id = job.post_id + and analysis.source_body_sha256 = job.source_body_sha256 + where job.post_id = post.post_id + )) + or ($3::boolean and not exists ( + select 1 + from post_content_ingestion_job job + join post_product_analysis product_analysis + on product_analysis.post_id = job.post_id + and product_analysis.source_body_sha256 = job.source_body_sha256 + where job.post_id = post.post_id + )) ) and ($5::boolean = ( $3::boolean - and ontology_project.post_id is not null - and job.source_body_sha256 is not null - and analysis.post_id is null + and exists ( + select 1 + from post_project_mention project + where project.post_id = post.post_id + and nullif(btrim(project.ontology_iri), '') is not null + ) + and exists ( + select 1 + from post_content_ingestion_job job + where job.post_id = post.post_id + and job.source_body_sha256 is not null + ) + and not exists ( + select 1 + from post_content_ingestion_job job + join operations_case_analysis analysis + on analysis.post_id = job.post_id + and analysis.source_body_sha256 = job.source_body_sha256 + where job.post_id = post.post_id + ) )) order by coalesce(post.event_occurred_at, post.created_at), post.created_at, @@ -717,41 +761,58 @@ async def republish_queued_post_content_jobs( pool: asyncpg.Pool, *, limit: int = 100, -) -> int: - """Recover queued rows and stale running leases when Valkey lost wake-ups.""" - async with pool.acquire() as conn: - rows = await conn.fetch( + after_eligible_at: datetime | None = None, + after_post_id: str | None = None, +) -> PostContentRecoveryPage: + """Republish one keyset page without starving rows beyond the first page.""" + if (after_eligible_at is None) != (after_post_id is None): + raise ValueError("recovery keyset requires both eligible_at and post_id") + + async def _fetch_page( + conn: asyncpg.Connection, + cursor_at: datetime | None, + cursor_id: str | None, + ) -> list[asyncpg.Record]: + return await conn.fetch( """ - select post_id, source_body_sha256 - from post_content_ingestion_job - where ( - status_code = $1 - and ( - next_attempt_at <= now() - or ( - next_attempt_at is null - and ( - attempt_count = 0 - or queued_at <= now() - $2::interval - ) - ) - ) + with recovery_candidate as ( + select post_id, + source_body_sha256, + case + when status_code = $1 then + case + when next_attempt_at is not null then next_attempt_at + when attempt_count = 0 then queued_at + else queued_at + $2::interval + end + when status_code = $3 and started_at is not null then + started_at + $4::interval + end as eligible_at + from post_content_ingestion_job + where status_code in ($1, $3) ) - or ( - status_code = $3 - and started_at is not null - and started_at < now() - $4::interval - ) - order by queued_at - limit $5 + select post_id, source_body_sha256, eligible_at + from recovery_candidate + where eligible_at <= now() + and ($5::timestamptz is null or (eligible_at, post_id) > ($5, $6::uuid)) + order by eligible_at, post_id + limit $7 """, QUEUED, POST_CONTENT_RETRY_INTERVAL, RUNNING, STALE_RUNNING_INTERVAL, + cursor_at, + cursor_id, limit, ) + + async with pool.acquire() as conn: + rows = await _fetch_page(conn, after_eligible_at, after_post_id) + if not rows and after_eligible_at is not None: + rows = await _fetch_page(conn, None, None) published = 0 + last_published_row: asyncpg.Record | None = None for row in rows: if await publish_post_content_event( client, @@ -759,7 +820,20 @@ async def republish_queued_post_content_jobs( source_body_digest=str(row["source_body_sha256"]), ): published += 1 - return published + last_published_row = row + else: + break + if last_published_row is None: + return PostContentRecoveryPage( + 0, + after_eligible_at, + after_post_id, + ) + return PostContentRecoveryPage( + published, + last_published_row["eligible_at"], + str(last_published_row["post_id"]), + ) def serialize_job_row(row: Any) -> dict[str, Any]: diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index f01ef119c..88f1887e2 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -6,6 +6,7 @@ import logging import time from collections.abc import Callable +from datetime import datetime from uuid import UUID import asyncpg @@ -26,6 +27,7 @@ ensure_post_content_job, post_content_is_complete, republish_queued_post_content_jobs, + trim_post_content_events_through, transition_post_content_job, ) from backend.app.operations_case_ingestion import persist_operations_cases @@ -628,6 +630,7 @@ async def process_post_content_job( settings.orchestrator_api_key, evidence_sources, ) + channel_stage_code = "product_analysis" try: await _persist_product_analysis_if_needed( pool, @@ -650,6 +653,7 @@ async def process_post_content_job( outcome="provider_unavailable", ) raise + channel_stage_code = "occupational_construct" construct_client = ( ContextualOrchestratorOccupationalConstructExtractionClient( settings.orchestrator_base_url, @@ -813,13 +817,15 @@ async def consume_post_content_stream_once( structure_factory=structure_factory, ) last_id = str(entry_id) + await trim_post_content_events_through(client, last_id) return last_id async def _recover_post_content_jobs( client: redis.Redis, pool: asyncpg.Pool, -) -> None: + recovery_cursor: tuple[datetime, str] | None = None, +) -> tuple[datetime, str] | None: """Persist the next bounded candidate page and republish queued wake-ups.""" settings = load_settings() require_orchestrator_evidence = bool( @@ -844,7 +850,17 @@ async def _recover_post_content_jobs( outcome="provider_unavailable", ) try: - await republish_queued_post_content_jobs(client, pool) + page = await republish_queued_post_content_jobs( + client, + pool, + after_eligible_at=recovery_cursor[0] if recovery_cursor else None, + after_post_id=recovery_cursor[1] if recovery_cursor else None, + ) + recovery_cursor = ( + (page.next_eligible_at, page.next_post_id) + if page.next_eligible_at is not None and page.next_post_id is not None + else None + ) except Exception as exc: # noqa: BLE001 - broker recovery is independent of selection. _logger.warning( "post-content wake-up recovery failed; retrying next cycle (error_type=%s)", @@ -855,6 +871,7 @@ async def _recover_post_content_jobs( exc, outcome="provider_unavailable", ) + return recovery_cursor async def run_post_content_worker( @@ -868,10 +885,13 @@ async def run_post_content_worker( """Run the at-least-once consumer and periodically recover queued rows.""" last_id = await _stream_tail(client) last_recovery = 0.0 + recovery_cursor: tuple[datetime, str] | None = None while True: now = time.monotonic() if now - last_recovery >= _RECOVERY_INTERVAL_SECONDS: - await _recover_post_content_jobs(client, pool) + recovery_cursor = await _recover_post_content_jobs( + client, pool, recovery_cursor + ) last_recovery = now try: last_id = await consume_post_content_stream_once( diff --git a/backend/app/project_history.py b/backend/app/project_history.py index 9389ef5fd..d203fb8c4 100644 --- a/backend/app/project_history.py +++ b/backend/app/project_history.py @@ -133,8 +133,31 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: order by role.post_id, role.actor_type_code, role.actor_name, role.responsibility """ _EDGE_SQL = """ -select edge.parent_post_id, edge.child_post_id, edge.fused_score +select edge.parent_post_id, edge.child_post_id, edge.fused_score, + temporal.observed as temporal_observed, + temporal.allen_relations, + temporal.artifact_digest_sha256 from post_lineage_edge edge + left join lateral ( + select relation.observed, + array_agg(kind.relation_code order by kind.relation_ordinal) as allen_relations, + artifact.artifact_digest_sha256 + from project_journey_temporal_relation relation + join project_journey_temporal_artifact artifact + on artifact.analysis_run_id = relation.analysis_run_id + join analysis_run temporal_run + on temporal_run.analysis_run_id = artifact.analysis_run_id + join project_journey_temporal_relation_kind kind + on kind.analysis_run_id = relation.analysis_run_id + and kind.left_post_id = relation.left_post_id + and kind.right_post_id = relation.right_post_id + where relation.left_post_id = edge.parent_post_id + and relation.right_post_id = edge.child_post_id + and temporal_run.knowledge_cutoff <= $2 + group by relation.observed, artifact.artifact_digest_sha256, artifact.admitted_at + order by artifact.admitted_at desc, artifact.artifact_digest_sha256 desc + limit 1 + ) temporal on true where edge.parent_post_id = any($1::uuid[]) and edge.child_post_id = any($1::uuid[]) order by edge.child_post_id, edge.parent_post_id @@ -219,6 +242,7 @@ async def fetch_project_history_projection( conn, visible_ids=visible_ids, normalized_key=normalized_key, + knowledge_cutoff=knowledge_cutoff, ) return build_project_history_projection( project_key=project_key, @@ -237,10 +261,11 @@ async def _fetch_project_children( *, visible_ids: Sequence[str], normalized_key: str, + knowledge_cutoff: datetime, ) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]], list[Mapping[str, Any]]]: """Fetch only child evidence whose endpoints are already authorized.""" matches = list(await conn.fetch(_MATCH_SQL, list(visible_ids), normalized_key)) roles = list(await conn.fetch(_ROLE_SQL, list(visible_ids))) - edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids))) + edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids), knowledge_cutoff)) return matches, roles, edges diff --git a/backend/app/project_journey_temporal.py b/backend/app/project_journey_temporal.py new file mode 100644 index 000000000..0d3cf214d --- /dev/null +++ b/backend/app/project_journey_temporal.py @@ -0,0 +1,117 @@ +"""Persist provider-owned temporal evidence for already-admitted journey edges.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from lineageweave.temporal_journey_artifact import ( + ALLEN_RELATIONS, + TemporalJourneyArtifact, + parse_temporal_journey_artifact, +) + + +class TemporalArtifactConnection(Protocol): + """Minimal transaction-scoped database port for artifact admission.""" + + async def fetchrow(self, query: str, *args: object) -> Any: + """Read one binding row.""" + + ... + + async def execute(self, query: str, *args: object) -> Any: + """Execute one immutable persistence statement.""" + + ... + + async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any: + """Execute bounded normalized child inserts.""" + + ... + + +class TemporalArtifactAdmissionError(ValueError): + """The artifact cannot be bound to the declared persisted run.""" + + +async def persist_project_journey_temporal_artifact( + conn: TemporalArtifactConnection, + *, + analysis_run_id: str, + payload: bytes, + expected_run_id: str, + expected_snapshot_id: str, + expected_input_digest_sha256: str, + expected_artifact_digest_sha256: str, +) -> TemporalJourneyArtifact: + """Validate and immutably persist temporal evidence for existing edges. + + The foreign key to ``post_lineage_edge`` is the semantic admission gate: + interval order can corroborate an admitted predecessor, but cannot create + a predecessor, branch, responsibility handoff, or causal transition. + """ + + artifact = parse_temporal_journey_artifact( + payload, + expected_run_id=expected_run_id, + expected_snapshot_id=expected_snapshot_id, + expected_input_digest_sha256=expected_input_digest_sha256, + expected_artifact_digest_sha256=expected_artifact_digest_sha256, + ) + binding = await conn.fetchrow( + "select remote_run_id from analysis_run_tepp_result where analysis_run_id = $1::uuid", + analysis_run_id, + ) + if binding is None or str(binding["remote_run_id"]) != expected_run_id: + raise TemporalArtifactAdmissionError("artifact run does not match a persisted terminal result") + existing = await conn.fetchrow( + "select artifact_digest_sha256 from project_journey_temporal_artifact " + "where analysis_run_id = $1::uuid for update", + analysis_run_id, + ) + if existing is not None: + if str(existing["artifact_digest_sha256"]) != expected_artifact_digest_sha256: + raise TemporalArtifactAdmissionError("analysis run already has a different artifact") + return artifact + await conn.execute( + "insert into project_journey_temporal_artifact " + "(analysis_run_id, remote_run_id, schema_version, snapshot_id, input_digest_sha256, artifact_digest_sha256) " + "values ($1::uuid, $2, $3, $4, $5, $6)", + analysis_run_id, + expected_run_id, + "tepp.tdt_chronos_interval_consistency.v1", + expected_snapshot_id, + expected_input_digest_sha256, + expected_artifact_digest_sha256, + ) + relation_rows = [ + (analysis_run_id, relation.left_event_id, relation.right_event_id, relation.observed) + for relation in artifact.relations + ] + await conn.executemany( + "insert into project_journey_temporal_relation " + "(analysis_run_id, left_post_id, right_post_id, observed) " + "values ($1::uuid, $2::uuid, $3::uuid, $4)", + relation_rows, + ) + await conn.executemany( + "insert into project_journey_temporal_relation_kind " + "(analysis_run_id, left_post_id, right_post_id, relation_code, relation_ordinal) " + "values ($1::uuid, $2::uuid, $3::uuid, $4, $5)", + [ + (analysis_run_id, relation.left_event_id, relation.right_event_id, code, ALLEN_RELATIONS.index(code)) + for relation in artifact.relations + for code in relation.allen_relations + ], + ) + await conn.executemany( + "insert into project_journey_temporal_support " + "(analysis_run_id, left_post_id, right_post_id, assertion_ordinal) " + "values ($1::uuid, $2::uuid, $3::uuid, $4)", + [ + (analysis_run_id, relation.left_event_id, relation.right_event_id, ordinal) + for relation in artifact.relations + for ordinal in relation.support_assertion_ordinals + ], + ) + return artifact diff --git a/backend/app/topic_influence_worker.py b/backend/app/topic_influence_worker.py new file mode 100644 index 000000000..b170407c8 --- /dev/null +++ b/backend/app/topic_influence_worker.py @@ -0,0 +1,502 @@ +"""Produce persisted topic influence through the external Rust authority.""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timezone +from typing import Any, Callable + +import asyncpg + +from lineageweave.http_client import HttpAdmissionDeferred, HttpClientError +from lineageweave.topic_influence_client import ( + TopicInfluenceClient, + TopicInfluenceInvalidResponse, + TopicInfluenceNotAvailable, + TopicInfluenceRequest, + TopicInfluenceResult, + build_topic_influence_request, +) + +_logger = logging.getLogger(__name__) + + +class TopicInfluenceInputChanged(RuntimeError): + """The source evidence changed after the external computation began.""" + + +class TopicInfluenceLeaseLost(RuntimeError): + """A different worker already owns or completed the claimed lease.""" + + +def _iso(value: object) -> str: + """Return a timezone-bearing ISO timestamp from trusted database evidence.""" + if not isinstance(value, datetime): + raise ValueError("topic influence timestamp evidence is missing") + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat() + + +async def load_topic_influence_request( + conn: asyncpg.Connection, topic_model_run_id: str +) -> TopicInfluenceRequest: + """Load one exact TEPP artifact and its normalized membership evidence.""" + model = await conn.fetchrow( + """ + select model.topic_model_run_id, model.tepp_run_id, + model.tepp_artifact_sha256, model.posterior_draw_set_id, + model.posterior_draw_count, model.coordinate_kind_code, + snapshot.snapshot_sha256, analysis.knowledge_cutoff + from topic_model_run model + join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = analysis.analysis_source_snapshot_id + where model.topic_model_run_id = $1 + and model.tepp_schema_version = 'tepp.topic_context_posterior.v1' + """, + topic_model_run_id, + ) + if model is None: + raise ValueError("TEPP independent topic artifact is not bound") + topics = [ + int(row["topic_index"]) + for row in await conn.fetch( + """ + select topic_index + from topic_definition + where topic_model_run_id = $1 + order by topic_index + """, + topic_model_run_id, + ) + ] + posts = await conn.fetch( + """ + select distinct membership.source_post_id, + coalesce(post.event_occurred_at, post.created_at) as event_time + from topic_context_membership membership + join source_post post on post.post_id = membership.source_post_id + where membership.topic_model_run_id = $1 + order by membership.source_post_id + """, + topic_model_run_id, + ) + has_unbound_membership = await conn.fetchval( + """ + select exists ( + select 1 + from topic_context_membership membership + left join provenance_assertion assertion + on assertion.assertion_id = membership.provenance_assertion_id + and assertion.relation_code = 'prov_was_derived_from' + left join provenance_resource_binding evidence + on evidence.resource_id = assertion.object_resource_id + and evidence.node_type_code = 'node_post' + and evidence.node_id = membership.source_post_id + where membership.topic_model_run_id = $1 + and (assertion.assertion_id is null or evidence.resource_id is null) + ) + """, + topic_model_run_id, + ) + if has_unbound_membership: + raise ValueError("topic membership provenance is incomplete") + observations: list[dict[str, Any]] = [] + for post in posts: + post_id = str(post["source_post_id"]) + coordinates = [ + { + "topic_index": int(row["topic_index"]), + "posterior_draw_ordinal": int(row["posterior_draw_ordinal"]), + "value": float(row["coordinate_value"]), + } + for row in await conn.fetch( + """ + select topic_index, posterior_draw_ordinal, coordinate_value + from topic_post_coordinate + where topic_model_run_id = $1 and source_post_id = $2 + order by topic_index, posterior_draw_ordinal + """, + topic_model_run_id, + post["source_post_id"], + ) + ] + memberships = [ + { + "membership_id": str(row["topic_context_membership_id"]), + "dimension_code": row["dimension_code"], + "context_id": row["context_id"], + "weight": float(row["membership_weight"]), + "valid_from": _iso(row["valid_from"]), + "valid_to": _iso(row["valid_to"]), + "evidence_sha256": row["evidence_sha256"], + "provenance_assertion_id": str(row["provenance_assertion_id"]), + } + for row in await conn.fetch( + """ + select membership.topic_context_membership_id, + membership.dimension_code, membership.context_id, + membership.membership_weight, membership.valid_from, + membership.valid_to, membership.evidence_sha256, + membership.provenance_assertion_id + from topic_context_membership membership + join provenance_assertion assertion + on assertion.assertion_id = membership.provenance_assertion_id + and assertion.relation_code = 'prov_was_derived_from' + join provenance_resource_binding evidence + on evidence.resource_id = assertion.object_resource_id + and evidence.node_type_code = 'node_post' + and evidence.node_id = membership.source_post_id + where membership.topic_model_run_id = $1 + and membership.source_post_id = $2 + order by membership.dimension_code, membership.context_id, + membership.topic_context_membership_id + """, + topic_model_run_id, + post["source_post_id"], + ) + ] + observations.append( + { + "post_id": post_id, + "event_time": _iso(post["event_time"]), + "coordinates": coordinates, + "memberships": memberships, + } + ) + return build_topic_influence_request( + tepp_run={ + "tepp_run_id": model["tepp_run_id"], + "tepp_artifact_sha256": model["tepp_artifact_sha256"], + "source_snapshot_sha256": model["snapshot_sha256"], + "knowledge_cutoff": _iso(model["knowledge_cutoff"]), + "posterior_draw_set_id": model["posterior_draw_set_id"], + "posterior_draw_count": int(model["posterior_draw_count"]), + "coordinate_kind_code": model["coordinate_kind_code"], + "topic_model_run_id": str(model["topic_model_run_id"]), + }, + topics=topics, + observations=observations, + ) + + +async def claim_topic_influence_job( + pool: asyncpg.Pool, + lease_timeout_seconds: int, +) -> tuple[str, TopicInfluenceRequest, str] | None: + """Lease the first complete queued request without holding provider I/O open.""" + async with pool.acquire() as conn: + await conn.execute( + """ + update topic_influence_job + set status_code = 'queued', started_at = null, + lease_expires_at = null, completed_at = null, + lease_token = null, failure_code = null, + request_sha256 = null, + not_before = clock_timestamp() + where status_code = 'running' + and lease_expires_at <= clock_timestamp() + """ + ) + candidates = await conn.fetch( + """ + select topic_model_run_id + from topic_influence_job + where status_code = 'queued' + and not_before <= clock_timestamp() + order by queued_at, topic_model_run_id + """ + ) + for candidate in candidates: + run_id = str(candidate["topic_model_run_id"]) + try: + request = await load_topic_influence_request(conn, run_id) + except (ValueError, TypeError, KeyError): + await conn.execute( + """ + update topic_influence_job + set status_code = 'awaiting_evidence', + failure_code = 'input_evidence_incomplete', + completed_at = clock_timestamp() + where topic_model_run_id = $1 and status_code = 'queued' + """, + run_id, + ) + # Close the transition race without polling incomplete input: + # evidence committed before the awaiting update is visible to + # this recheck; evidence committed afterwards fires a wake + # trigger against the already-awaiting row. + try: + await load_topic_influence_request(conn, run_id) + except (ValueError, TypeError, KeyError): + continue + await conn.execute( + """ + update topic_influence_job + set status_code = 'queued', failure_code = null, + completed_at = null, not_before = clock_timestamp() + where topic_model_run_id = $1 + and status_code = 'awaiting_evidence' + """, + run_id, + ) + continue + async with conn.transaction(): + lease_token = str(uuid.uuid4()) + claimed = await conn.fetchval( + """ + update topic_influence_job + set status_code = 'running', request_sha256 = $2, + attempt_count = attempt_count + 1, + started_at = clock_timestamp(), completed_at = null, + failure_code = null, + lease_token = $4::uuid, + lease_expires_at = clock_timestamp() + + make_interval(secs => $3) + where topic_model_run_id = $1 and status_code = 'queued' + returning topic_model_run_id + """, + run_id, + request.request_sha256, + lease_timeout_seconds, + lease_token, + ) + if claimed is not None: + return run_id, request, lease_token + return None + + +async def persist_topic_influence_result( + pool: asyncpg.Pool, + topic_model_run_id: str, + request: TopicInfluenceRequest, + result: TopicInfluenceResult, + lease_token: str, +) -> None: + """Persist one complete result after rechecking the current input digest.""" + payload = result.payload + async with pool.acquire() as conn: + async with conn.transaction(): + job = await conn.fetchrow( + """ + select request_sha256, lease_token::text as lease_token + from topic_influence_job + where topic_model_run_id = $1 and status_code = 'running' + for update + """, + topic_model_run_id, + ) + if ( + job is None + or job["request_sha256"] != request.request_sha256 + or job["lease_token"] != lease_token + ): + raise TopicInfluenceLeaseLost( + "topic influence job lease no longer matches" + ) + try: + current = await load_topic_influence_request(conn, topic_model_run_id) + except (ValueError, TypeError, KeyError) as exc: + raise TopicInfluenceInputChanged( + "topic influence evidence became incomplete during computation" + ) from exc + if current.request_sha256 != request.request_sha256: + raise TopicInfluenceInputChanged( + "topic influence input changed during computation" + ) + influence_run_id = await conn.fetchval( + """ + insert into topic_influence_run + (topic_model_run_id, fast_mlsirm_schema_version, + fast_mlsirm_version, fast_mlsirm_code_revision, + fast_mlsirm_artifact_sha256, reported_tepp_run_id, + reported_snapshot_sha256, reported_knowledge_cutoff, + membership_fingerprint_sha256, compute_backend_code, + precision_code, posterior_draw_coverage, + convergence_status_code, identification_status_code, + parity_status_code) + values ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9, + $10, $11, $12, $13, $14, $15) + returning topic_influence_run_id + """, + topic_model_run_id, + payload["schema_version"], + payload["producer_version"], + payload["code_revision"], + payload["artifact_sha256"], + payload["tepp_run_id"], + payload["source_snapshot_sha256"], + payload["knowledge_cutoff"], + payload["membership_fingerprint_sha256"], + payload["compute_backend_code"], + payload["precision_code"], + payload["posterior_draw_coverage"], + payload["convergence_status_code"], + payload["identification_status_code"], + payload["parity_status_code"], + ) + for influence in payload["influences"]: + await conn.execute( + """ + insert into topic_post_context_influence + (topic_model_run_id, topic_influence_run_id, + topic_context_membership_id, topic_index, + influence_value, uncertainty_method_code, + uncertainty_lower_value, uncertainty_upper_value, + diagnostic_status_code) + values ($1, $2, $3::uuid, $4, $5, $6, $7, $8, $9) + """, + topic_model_run_id, + influence_run_id, + influence["membership_id"], + influence["topic_index"], + influence["influence_value"], + influence["uncertainty_method_code"], + influence["uncertainty_lower_value"], + influence["uncertainty_upper_value"], + influence["diagnostic_status_code"], + ) + await conn.execute( + """ + update topic_influence_job + set status_code = 'succeeded', completed_at = clock_timestamp(), + lease_expires_at = null, lease_token = null + where topic_model_run_id = $1 and status_code = 'running' + and lease_token = $2::uuid + """, + topic_model_run_id, + lease_token, + ) + + +async def _fail_job( + pool: asyncpg.Pool, run_id: str, lease_token: str, failure_code: str +) -> None: + """Record a bounded failure without persisting provider content.""" + async with pool.acquire() as conn: + await conn.execute( + """ + update topic_influence_job + set status_code = 'failed', failure_code = $3, + completed_at = clock_timestamp(), lease_expires_at = null, + lease_token = null, request_sha256 = null + where topic_model_run_id = $1 and status_code = 'running' + and lease_token = $2::uuid + """, + run_id, + lease_token, + failure_code, + ) + + +async def _defer_job( + pool: asyncpg.Pool, run_id: str, lease_token: str, retry_after_seconds: int +) -> None: + """Requeue a remotely deferred job at the exact admitted retry instant.""" + async with pool.acquire() as conn: + await conn.execute( + """ + update topic_influence_job + set status_code = 'queued', started_at = null, completed_at = null, + failure_code = null, + not_before = clock_timestamp() + make_interval(secs => $3), + lease_expires_at = null, lease_token = null, + request_sha256 = null + where topic_model_run_id = $1 and status_code = 'running' + and lease_token = $2::uuid + """, + run_id, + lease_token, + retry_after_seconds, + ) + + +async def requeue_topic_influence_job(pool: asyncpg.Pool, run_id: str) -> bool: + """Explicitly requeue one failed job after an operator resolves its cause.""" + async with pool.acquire() as conn: + updated = await conn.fetchval( + """ + update topic_influence_job + set status_code = 'queued', started_at = null, completed_at = null, + failure_code = null, not_before = clock_timestamp(), + lease_expires_at = null, lease_token = null, + request_sha256 = null + where topic_model_run_id = $1 and status_code = 'failed' + returning topic_model_run_id + """, + run_id, + ) + return updated is not None + + +async def _release_changed_job( + pool: asyncpg.Pool, run_id: str, lease_token: str +) -> None: + """Release a stale lease so the next claim rebuilds the changed request.""" + async with pool.acquire() as conn: + await conn.execute( + """ + update topic_influence_job + set status_code = 'queued', started_at = null, completed_at = null, + failure_code = null, not_before = clock_timestamp(), + lease_expires_at = null, lease_token = null, + request_sha256 = null + where topic_model_run_id = $1 and status_code = 'running' + and lease_token = $2::uuid + """, + run_id, + lease_token, + ) + + +async def process_topic_influence_job( + pool: asyncpg.Pool, client: TopicInfluenceClient +) -> bool: + """Produce at most one queued result and return whether work was claimed.""" + claimed = await claim_topic_influence_job(pool, client.lease_timeout_seconds) + if claimed is None: + return False + run_id, request, lease_token = claimed + try: + result = await asyncio.to_thread(client.estimate, request) + await persist_topic_influence_result( + pool, run_id, request, result, lease_token + ) + except HttpAdmissionDeferred as exc: + await _defer_job(pool, run_id, lease_token, exc.retry_after_seconds) + except TopicInfluenceInputChanged: + await _release_changed_job(pool, run_id, lease_token) + except TopicInfluenceLeaseLost: + _logger.info("Topic influence lease changed before result persistence") + except (TopicInfluenceNotAvailable, HttpClientError, OSError, TimeoutError): + await _fail_job(pool, run_id, lease_token, "producer_unavailable") + except TopicInfluenceInvalidResponse: + await _fail_job(pool, run_id, lease_token, "producer_result_invalid") + except Exception: # noqa: BLE001 - failure is bounded and the worker continues. + _logger.exception("topic influence production failed") + await _fail_job(pool, run_id, lease_token, "persistence_failed") + return True + + +async def run_topic_influence_worker( + pool: asyncpg.Pool, + client_factory: Callable[[], TopicInfluenceClient], + *, + poll_seconds: float, +) -> None: + """Poll the durable lease table and keep the shared worker responsive.""" + while True: + try: + worked = await process_topic_influence_job(pool, client_factory()) + except (asyncpg.PostgresError, OSError, TimeoutError): + _logger.exception( + "Topic influence could not claim database work; verify database " + "connectivity before the next poll" + ) + await asyncio.sleep(poll_seconds) + continue + if not worked: + await asyncio.sleep(poll_seconds) diff --git a/backend/app/worker.py b/backend/app/worker.py index 06bfaee10..1241aa5fc 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -3,6 +3,12 @@ from __future__ import annotations import asyncio +import logging +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from urllib.parse import urlsplit + +import asyncpg from backend.app.activity_stream import create_valkey_client from backend.app.analysis_run_start import configured_tepp_client @@ -20,8 +26,90 @@ _vision_client, ) from backend.app.post_content_worker import run_post_content_worker +from backend.app.topic_influence_worker import run_topic_influence_worker from backend.app.worker_health import run_worker_heartbeat from lineageweave.observability import configure_telemetry, shutdown_telemetry +from lineageweave.topic_influence_client import HttpTopicInfluenceClient + +_WORKER_LEASE_NAME = "lineageweave_durable_queue_worker" +_logger = logging.getLogger(__name__) + + +def _topic_influence_timeouts(settings: object) -> tuple[int, int, int]: + """Return a declared request/lease pair with persistence time remaining.""" + request_timeout = getattr( + settings, "topic_influence_request_timeout_seconds", None + ) + lease_timeout = getattr(settings, "topic_influence_lease_timeout_seconds", None) + poll_seconds = getattr(settings, "topic_influence_poll_seconds", None) + if ( + type(request_timeout) is not int + or type(lease_timeout) is not int + or request_timeout <= 0 + or lease_timeout <= request_timeout + or type(poll_seconds) is not int + or poll_seconds <= 0 + ): + raise ValueError( + "topic influence lease timeout must be a declared positive integer " + "strictly greater than the declared positive request timeout, with a " + "declared positive poll interval" + ) + return request_timeout, lease_timeout, poll_seconds + + +def _optional_topic_influence_timeouts( + settings: object, *, transport_url: object +) -> tuple[int, int, int] | None: + """Disable only optional influence work when its endpoint contract is invalid.""" + if not transport_url: + return None + if not isinstance(transport_url, str): + _logger.error( + "Topic influence is disabled; declare an absolute HTTP or HTTPS " + "transport URL before enabling this consumer" + ) + return None + parsed = urlsplit(transport_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or not parsed.hostname + ): + _logger.error( + "Topic influence is disabled; declare an absolute HTTP or HTTPS " + "transport URL before enabling this consumer" + ) + return None + try: + return _topic_influence_timeouts(settings) + except ValueError: + _logger.error( + "Topic influence is disabled; declare a positive lease timeout strictly " + "greater than its request timeout before enabling this consumer" + ) + return None + + +@asynccontextmanager +async def _single_worker_lease(pool: asyncpg.Pool) -> AsyncIterator[None]: + """Fail a second worker process before two stream cursors can race.""" + async with pool.acquire() as conn: + acquired = bool( + await conn.fetchval( + "select pg_try_advisory_lock(hashtextextended($1, 0))", + _WORKER_LEASE_NAME, + ) + ) + if not acquired: + raise RuntimeError("another durable queue worker already owns the lease") + try: + yield + finally: + await conn.fetchval( + "select pg_advisory_unlock(hashtextextended($1, 0))", + _WORKER_LEASE_NAME, + ) async def run_worker_process() -> None: @@ -30,48 +118,73 @@ async def run_worker_process() -> None: settings = load_settings() pool = await create_pool(settings.database_url) valkey = create_valkey_client(settings.valkey_url) - workers = ( - asyncio.create_task(run_worker_heartbeat()), - asyncio.create_task( - run_analysis_run_worker( - valkey, - pool, - database_url=settings.database_url, - tepp_client=configured_tepp_client( - settings.tepp_transport_url, - settings.tepp_api_key, - ), - adjudication_client=_adjudication_client(), + try: + async with _single_worker_lease(pool): + topic_influence_url = getattr( + settings, "topic_influence_transport_url", "" ) - ), - asyncio.create_task( - run_post_content_worker( - valkey, - pool, - vision_factory=_vision_client, - embedding_factory=_embedding_client, - structure_factory=_post_structure_client, + influence_timeouts = _optional_topic_influence_timeouts( + settings, transport_url=topic_influence_url ) - ), - asyncio.create_task( - run_global_ask_worker( - valkey, - pool, - chat_factory=lambda: _post_chat_client( - timeout=load_settings().orchestrator_answer_timeout_seconds + workers = [ + asyncio.create_task(run_worker_heartbeat()), + asyncio.create_task( + run_analysis_run_worker( + valkey, + pool, + database_url=settings.database_url, + tepp_client=configured_tepp_client( + settings.tepp_transport_url, + settings.tepp_api_key, + ), + adjudication_client=_adjudication_client(), + ) ), - embedding_factory=_embedding_client, - semantic_query_factory=_semantic_query_client, - claim_verification_factory=_claim_verification_client_factory, - ) - ), - ) - try: - await asyncio.gather(*workers) + asyncio.create_task( + run_post_content_worker( + valkey, + pool, + vision_factory=_vision_client, + embedding_factory=_embedding_client, + structure_factory=_post_structure_client, + ) + ), + asyncio.create_task( + run_global_ask_worker( + valkey, + pool, + chat_factory=lambda: _post_chat_client( + timeout=load_settings().orchestrator_answer_timeout_seconds + ), + embedding_factory=_embedding_client, + semantic_query_factory=_semantic_query_client, + claim_verification_factory=_claim_verification_client_factory, + ) + ), + ] + if topic_influence_url and influence_timeouts is not None: + request_timeout, lease_timeout, poll_seconds = influence_timeouts + workers.append( + asyncio.create_task( + run_topic_influence_worker( + pool, + lambda: HttpTopicInfluenceClient( + topic_influence_url, + getattr(settings, "topic_influence_api_key", ""), + timeout=float(request_timeout), + lease_timeout_seconds=lease_timeout, + ), + poll_seconds=float(poll_seconds), + ) + ) + ) + try: + await asyncio.gather(*workers) + finally: + for worker in workers: + worker.cancel() + await asyncio.gather(*workers, return_exceptions=True) finally: - for worker in workers: - worker.cancel() - await asyncio.gather(*workers, return_exceptions=True) try: await pool.close() finally: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 45f5740f6..b0a1cb8e5 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -237,6 +237,7 @@ "0250_operations_case_analysis_input.sql", "0251_product_semantic_catalog.sql", "0253_voice_semantic_taxonomy.sql", + "0257_public_claim_envelope.sql", ) ) @@ -5678,6 +5679,47 @@ def verify(self, claim): """, (seeded_db["public_post_id"],), ) + cur.execute( + "insert into provenance_resource (resource_iri, resource_label) " + "values ('urn:lineageweave:test:public-claim', 'Synthetic public claim') " + "returning resource_id" + ) + claim_resource_id = cur.fetchone()[0] + cur.execute( + "insert into provenance_resource_type (resource_id, class_code) " + "values (%s, 'prov_entity')", + (claim_resource_id,), + ) + cur.execute( + "insert into provenance_resource (resource_iri, resource_label) " + "values ('urn:lineageweave:test:public-post-evidence', 'Synthetic source post') " + "returning resource_id" + ) + post_resource_id = cur.fetchone()[0] + cur.execute( + "insert into provenance_resource_type (resource_id, class_code) " + "values (%s, 'prov_entity')", + (post_resource_id,), + ) + cur.execute( + "insert into provenance_resource_binding (resource_id, node_type_code, node_id) " + "values (%s, 'node_post', %s)", + (post_resource_id, seeded_db["public_post_id"]), + ) + cur.execute( + "insert into provenance_assertion " + "(subject_resource_id, relation_code, object_resource_id) " + "values (%s, 'prov_was_derived_from', %s) returning assertion_id", + (claim_resource_id, post_resource_id), + ) + assertion_id = cur.fetchone()[0] + cur.execute( + "insert into public_claim_envelope " + "(source_post_id, provenance_assertion_id, claim_kind_code, claim_text, egress_eligible) " + "values (%s, %s, 'claim_public_event', " + "'Synthetic Apollo event was published.', true)", + (seeded_db["public_post_id"], assertion_id), + ) conn.commit() monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient()) diff --git a/docker-compose.yml b/docker-compose.yml index c91a2bf08..8c011980b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,7 +105,9 @@ services: build: context: ./docker/contextual-orchestrator dockerfile: Dockerfile - image: ${COMPOSE_PROJECT_NAME:-lineageweave}-orchestrator:1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89 + args: + CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION: 3558a9a3aeb985282b255fcd80bb2201c19ae54b + image: ${COMPOSE_PROJECT_NAME:-lineageweave}-orchestrator:3558a9a3aeb985282b255fcd80bb2201c19ae54b env_file: - ${HOME}/.env environment: @@ -182,9 +184,15 @@ services: # LLM_GATEWAY_API_KEY in the orchestrator's private env file. ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-http://orchestrator:8000} ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}} + ORCHESTRATOR_ROUTING_ENDPOINT: ${ORCHESTRATOR_ROUTING_ENDPOINT:-} SEARXNG_BASE_URL: http://searxng:8080 TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-} TEPP_API_KEY: ${TEPP_API_KEY:-} + TOPIC_INFLUENCE_TRANSPORT_URL: ${TOPIC_INFLUENCE_TRANSPORT_URL:-} + TOPIC_INFLUENCE_API_KEY: ${TOPIC_INFLUENCE_API_KEY:-} + TOPIC_INFLUENCE_REQUEST_TIMEOUT_SECONDS: ${TOPIC_INFLUENCE_REQUEST_TIMEOUT_SECONDS:-} + TOPIC_INFLUENCE_LEASE_TIMEOUT_SECONDS: ${TOPIC_INFLUENCE_LEASE_TIMEOUT_SECONDS:-} + TOPIC_INFLUENCE_POLL_SECONDS: ${TOPIC_INFLUENCE_POLL_SECONDS:-} CALDAV_BASE_URL: ${CALDAV_BASE_URL:-} NARUON_CALENDAR_BASE_URL: ${NARUON_CALENDAR_BASE_URL:-} NARUON_CALENDAR_SERVICE_TOKEN: ${NARUON_CALENDAR_SERVICE_TOKEN:-} @@ -245,6 +253,8 @@ services: build: context: . dockerfile: backend/Dockerfile + args: + LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown} command: ["uvicorn", "backend.app.mcp_server:app", "--host", "0.0.0.0", "--port", "8001"] environment: DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave} @@ -265,6 +275,7 @@ services: VALKEY_URL: redis://valkey:6379/0 ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-http://orchestrator:8000} ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}} + ORCHESTRATOR_ROUTING_ENDPOINT: ${ORCHESTRATOR_ROUTING_ENDPOINT:-} MCP_RESOURCE_URL: http://localhost:18001/mcp MCP_AUDIENCE: http://localhost:18001/mcp MCP_ALLOWED_HOSTS: localhost:*,127.0.0.1:*,mcp:8001 diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile index d793976d2..73694e3df 100644 --- a/docker/contextual-orchestrator/Dockerfile +++ b/docker/contextual-orchestrator/Dockerfile @@ -1,24 +1,49 @@ +# Build the exact Rust token-packer shipped by the pinned upstream archive. +# The native module is mandatory: token budgets, vector reductions, and RMSE +# fail closed rather than falling back to Python arithmetic. +ARG MATURIN_BUILDER_IMAGE=ghcr.io/pyo3/maturin@sha256:b6c8b59a0170b77eb31a35b56034abd39972483ad0ebfff344deaa42a85f3bd3 +FROM ${MATURIN_BUILDER_IMAGE} AS token-builder + +ADD --checksum=sha256:8dcd15b023aa1205a091d7826e278713d43bc1981dbd6a7189a7382e7f69cad3 https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/3558a9a3aeb985282b255fcd80bb2201c19ae54b.tar.gz /tmp/contextual-orchestrator.tar.gz +RUN mkdir -p /build/contextual-orchestrator \ + && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 \ + -C /build/contextual-orchestrator \ + && rm /tmp/contextual-orchestrator.tar.gz +WORKDIR /build/contextual-orchestrator/rust/token_counter +RUN maturin build --locked --release --out /build/wheels \ + && set -- /build/wheels/*.whl \ + && test "$#" -eq 1 \ + && test -f "$1" + FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf WORKDIR /app +ARG CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION=unknown +LABEL org.opencontainers.image.revision=${CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION} + # Reuse the upstream implementation without copying it into LineageWeave. # Pin the runtime to a reviewed immutable upstream commit; model selection, # structured synthesis, and reasoning policy stay in contextual-orchestrator. -ADD --checksum=sha256:243e3efa9a0c2a07cd02e4620c7a40fee8b8d9c04d7df394136ab60f2635a45d https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz COPY requirements.lock /tmp/orchestrator-requirements.lock -RUN mkdir /tmp/contextual-orchestrator \ - && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \ - && cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \ - && cp -R /tmp/contextual-orchestrator/examples /app/examples \ - && rm -rf /tmp/contextual-orchestrator /tmp/contextual-orchestrator.tar.gz \ - && python -m pip install --no-cache-dir --require-hashes \ +COPY --from=token-builder /build/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator +COPY --from=token-builder /build/contextual-orchestrator/examples /app/examples +COPY --from=token-builder /build/wheels /tmp/token-wheels +RUN python -m pip install --no-cache-dir --require-hashes \ -r /tmp/orchestrator-requirements.lock \ && rm /tmp/orchestrator-requirements.lock \ + && set -- /tmp/token-wheels/*.whl \ + && test "$#" -eq 1 \ + && test -f "$1" \ + && python -m pip install --no-cache-dir --no-deps "$1" \ + && rm -rf /tmp/token-wheels \ && useradd --uid 10001 --no-create-home orchestrator COPY agents.json /app/agents.json COPY start.py /app/start.py +COPY verify_startup_contract.py /app/verify_startup_contract.py +RUN python /app/verify_startup_contract.py \ + && rm /app/verify_startup_contract.py ENV AGENTS_FILE=/app/agents.json \ PORT=8000 diff --git a/docker/contextual-orchestrator/requirements.lock b/docker/contextual-orchestrator/requirements.lock index 2353e7e74..9ba761162 100644 --- a/docker/contextual-orchestrator/requirements.lock +++ b/docker/contextual-orchestrator/requirements.lock @@ -1,9 +1,122 @@ # This file was autogenerated by uv via the following command: -# uv pip compile docker/contextual-orchestrator/requirements.in --universal --python-version 3.12 --generate-hashes --output-file docker/contextual-orchestrator/requirements.lock +# uv pip compile /tmp/contextual-4db-upstream.txt docker/contextual-orchestrator/requirements.in --generate-hashes --universal --output-file docker/contextual-orchestrator/requirements.lock +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # -r /tmp/contextual-4db-upstream.txt + # jsonschema + # referencing certifi==2026.7.22 \ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 - # via requests + # via + # -r /tmp/contextual-4db-upstream.txt + # requests +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via + # -r /tmp/contextual-4db-upstream.txt + # cryptography charset-normalizer==3.5.1 \ --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \ --hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \ @@ -177,19 +290,84 @@ charset-normalizer==3.5.1 \ --hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \ --hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \ --hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f - # via requests -googleapis-common-protos==1.75.2 \ - --hash=sha256:6b83302f554ea93a0f48409c7fc2050f954bcbcddb7e3a9c76d4a823cb22920e \ - --hash=sha256:8829a3d1e4508c5b7b9a6b9525f7fccff611f8531644579a76466c29295d4bb2 - # via opentelemetry-exporter-otlp-proto-http + # via + # -r /tmp/contextual-4db-upstream.txt + # requests +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 + # via -r /tmp/contextual-4db-upstream.txt +googleapis-common-protos==1.75.1 \ + --hash=sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79 \ + --hash=sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071 + # via + # -r /tmp/contextual-4db-upstream.txt + # opentelemetry-exporter-otlp-proto-http idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 - # via requests + # via + # -r /tmp/contextual-4db-upstream.txt + # requests +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via -r /tmp/contextual-4db-upstream.txt +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via + # -r /tmp/contextual-4db-upstream.txt + # jsonschema opentelemetry-api==1.44.0 \ --hash=sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a \ --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef # via + # -r /tmp/contextual-4db-upstream.txt # -r docker/contextual-orchestrator/requirements.in # opentelemetry-exporter-otlp-proto-http # opentelemetry-sdk @@ -197,27 +375,35 @@ opentelemetry-api==1.44.0 \ opentelemetry-exporter-otlp-proto-common==1.44.0 \ --hash=sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694 \ --hash=sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac - # via opentelemetry-exporter-otlp-proto-http + # via + # -r /tmp/contextual-4db-upstream.txt + # opentelemetry-exporter-otlp-proto-http opentelemetry-exporter-otlp-proto-http==1.44.0 \ --hash=sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3 \ --hash=sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8 - # via -r docker/contextual-orchestrator/requirements.in + # via + # -r /tmp/contextual-4db-upstream.txt + # -r docker/contextual-orchestrator/requirements.in opentelemetry-proto==1.44.0 \ --hash=sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56 \ --hash=sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3 # via + # -r /tmp/contextual-4db-upstream.txt # opentelemetry-exporter-otlp-proto-common # opentelemetry-exporter-otlp-proto-http opentelemetry-sdk==1.44.0 \ --hash=sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b \ --hash=sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad # via + # -r /tmp/contextual-4db-upstream.txt # -r docker/contextual-orchestrator/requirements.in # opentelemetry-exporter-otlp-proto-http opentelemetry-semantic-conventions==0.65b0 \ --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb \ --hash=sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60 - # via opentelemetry-sdk + # via + # -r /tmp/contextual-4db-upstream.txt + # opentelemetry-sdk protobuf==7.36.0 \ --hash=sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488 \ --hash=sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16 \ @@ -228,16 +414,158 @@ protobuf==7.36.0 \ --hash=sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44 \ --hash=sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea # via + # -r /tmp/contextual-4db-upstream.txt # googleapis-common-protos # opentelemetry-proto +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via + # -r /tmp/contextual-4db-upstream.txt + # cffi +redis==8.1.0 \ + --hash=sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25 \ + --hash=sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb + # via -r /tmp/contextual-4db-upstream.txt +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # -r /tmp/contextual-4db-upstream.txt + # jsonschema + # jsonschema-specifications requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed - # via opentelemetry-exporter-otlp-proto-http + # via + # -r /tmp/contextual-4db-upstream.txt + # opentelemetry-exporter-otlp-proto-http +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # -r /tmp/contextual-4db-upstream.txt + # jsonschema + # referencing typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via + # -r /tmp/contextual-4db-upstream.txt # opentelemetry-api # opentelemetry-exporter-otlp-proto-http # opentelemetry-sdk @@ -245,4 +573,6 @@ typing-extensions==4.16.0 \ urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 - # via requests + # via + # -r /tmp/contextual-4db-upstream.txt + # requests diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py index 01dc5d189..3b9e4d95a 100644 --- a/docker/contextual-orchestrator/start.py +++ b/docker/contextual-orchestrator/start.py @@ -23,6 +23,24 @@ def _pop_first_env(*names: str) -> str: return first +def _configured_agents(agents: dict[str, object], provider_url: str) -> dict[str, object]: + """Bind seed agents to the trusted configured-gateway discovery boundary.""" + configured = json.loads(json.dumps(agents)) + raw_agents = configured.get("agents") + if not isinstance(raw_agents, list): + raise SystemExit("agents.json must contain an agents list") + for agent in raw_agents: + if not isinstance(agent, dict): + raise SystemExit("agents.json entries must be objects") + agent["base_url"] = provider_url + agent["credential_key"] = "LLM_GATEWAY_API_KEY" + agent["provider_name"] = "configured_gateway" + if not str(agent.get("model", "")).strip(): + agent["tags"] = list(dict.fromkeys((*agent.get("tags", []), "bootstrap_seed"))) + agent.setdefault("provider_protocol", "auto") + return configured + + def main() -> None: """Register the provider credential and delegate to the upstream server.""" gateway_key = _pop_first_env("LLM_GATEWAY_API_KEY", "LLM_API_KEY") @@ -48,6 +66,7 @@ def main() -> None: raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway") if not provider_url.rstrip("/").endswith("/v1"): provider_url = provider_url.rstrip("/") + "/v1" + batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip() raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip() try: max_output_tokens = int(raw_limit) @@ -63,20 +82,22 @@ def main() -> None: if not 64 * 1024 <= max_body_bytes <= 64 * 1024 * 1024: raise SystemExit("CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES must be between 65536 and 67108864") agents_path = Path("/tmp/lineageweave-agents.json") - agents = json.loads(Path("/app/agents.json").read_text(encoding="utf-8")) - for agent in agents["agents"]: - agent["base_url"] = provider_url - agent["credential_key"] = "LLM_GATEWAY_API_KEY" - agent.setdefault("provider_protocol", "auto") + agents = _configured_agents( + json.loads(Path("/app/agents.json").read_text(encoding="utf-8")), + provider_url, + ) os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None) agents_path.write_text(json.dumps(agents), encoding="utf-8") from contextual_orchestrator.credentials import register_credential register_credential("LLM_GATEWAY_API_KEY", gateway_key) + if batch_registry_url: + register_credential("batch_job_registry_valkey_url", batch_registry_url) for credential_name, credential_value in provider_credentials.items(): register_credential(credential_name, credential_value) del gateway_key + del batch_registry_url del provider_credentials sys.argv = [ "contextual_orchestrator", @@ -84,7 +105,6 @@ def main() -> None: "--agents", str(agents_path), "--auto-discover-model-agents", - "--allow-discovery-failures", "--host", "0.0.0.0", "--port", diff --git a/docker/contextual-orchestrator/verify_startup_contract.py b/docker/contextual-orchestrator/verify_startup_contract.py new file mode 100644 index 000000000..202e48ed7 --- /dev/null +++ b/docker/contextual-orchestrator/verify_startup_contract.py @@ -0,0 +1,105 @@ +"""Build-time integration proof for the pinned gateway discovery seam.""" + +from __future__ import annotations + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +import contextual_orchestrator.__main__ as entrypoint +from contextual_orchestrator.credentials import ( + InMemoryCredentialBackend, + register_credential, + set_backend, +) +from contextual_orchestrator.model_discovery import DiscoveredModel +from contextual_orchestrator.orchestrator import ( + ModelClient, + TaskOrchestrator, + load_agents, +) +from contextual_orchestrator.server import _run_with_routing_endpoint +from contextual_orchestrator.token_counting import RustCl100kPacker +from start import _configured_agents + + +def main() -> None: + """Prove wrapper output expands into a same-origin concrete serving pool.""" + token_packer = RustCl100kPacker() + assert token_packer.count_text("hello") == 1 + + gateway_origin = "https://gateway.synthetic.example/v1" + configured = _configured_agents( + json.loads(Path("/app/agents.json").read_text(encoding="utf-8")), + gateway_origin, + ) + with TemporaryDirectory() as directory: + agents_path = Path(directory) / "agents.json" + agents_path.write_text(json.dumps(configured), encoding="utf-8") + loaded = load_agents(str(agents_path)) + + configured_model = DiscoveredModel( + provider_name="configured_gateway", + model_id="catalog-chat-model", + credential_name="LLM_GATEWAY_API_KEY", + chat_base_url=gateway_origin, + auth_scheme="Bearer", + capabilities=("chat",), + ) + unrelated_models = [ + DiscoveredModel( + provider_name="synthetic_provider", + model_id=f"other-chat-model-{index}", + credential_name="SYNTHETIC_PROVIDER_KEY", + chat_base_url="https://other.synthetic.example/v1", + auth_scheme="Bearer", + capabilities=("chat",), + ) + for index in range(20) + ] + catalog = [configured_model, *unrelated_models] + set_backend(InMemoryCredentialBackend()) + register_credential("LLM_GATEWAY_API_KEY", "synthetic-secret") + orchestrator = TaskOrchestrator( + loaded, + client=ModelClient( + allowed_provider_hosts={ + "gateway.synthetic.example", + "other.synthetic.example", + } + ), + ) + original_discovery = entrypoint.discover_all_models + entrypoint.discover_all_models = lambda _sources: (catalog, []) + try: + entrypoint._auto_discover_runtime_agents(orchestrator) + finally: + entrypoint.discover_all_models = original_discovery + + active_gateway = [ + agent + for agent in orchestrator.agents + if agent.provider_name == "configured_gateway" + ] + assert len(active_gateway) == 1 + assert active_gateway[0].model == "catalog-chat-model" + assert all(agent.model for agent in orchestrator.agents) + session_metadata = {"session_id": "synthetic-post-session"} + + def selected_request() -> dict[str, str]: + candidates = orchestrator._ranked_agents("synthetic request", "worker") + assert [agent.id for agent in candidates] == [active_gateway[0].id] + return session_metadata + + result = _run_with_routing_endpoint( + orchestrator, + {"endpoint": "https://gateway.synthetic.example"}, + TaskOrchestrator.GATEWAY_DEFAULT_MODEL, + selected_request, + ) + assert result == {"session_id": "synthetic-post-session"} + assert session_metadata == {"session_id": "synthetic-post-session"} + + +if __name__ == "__main__": + main() diff --git a/docs/adr/0070-contextual-orchestrator-upstream-integration.md b/docs/adr/0070-contextual-orchestrator-upstream-integration.md index de06a4128..8b8d455fb 100644 --- a/docs/adr/0070-contextual-orchestrator-upstream-integration.md +++ b/docs/adr/0070-contextual-orchestrator-upstream-integration.md @@ -38,6 +38,15 @@ must include its own unit and integration tests and be merged through its normal review process. LineageWeave then pins the reviewed immutable upstream commit in its Docker build and uses only the published orchestrator contract. +An operator may set `ORCHESTRATOR_ROUTING_ENDPOINT` at the backend, worker, +and MCP process boundary. LineageWeave adds that opaque selector as +`routing.endpoint` only to contextual-orchestrator requests whose parsed path +is exactly `/v1/chat/completions` or `/v1/responses`. Existing routing fields +are preserved; a non-object routing value or a conflicting endpoint fails +before transport. The selector is not applied to embeddings, batch routes, +model discovery, or other HTTP services, and an unset selector retains the +existing automatic routing behavior. + Until that commit is available, the affected capability is unavailable rather than silently routed through a local patch or a guessed model. A LineageWeave change is complete only when the pinned upstream commit starts successfully diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md index a12ae36ca..6040889c2 100644 --- a/docs/adr/0083-orchestrator-runtime-commit-pin.md +++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md @@ -15,9 +15,16 @@ multi-agent. ## Decision `docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to -commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit -and immutable until the reviewed upstream change is superseded; it is not a -moving `main` reference and it is not a LineageWeave monkey patch. +commit `3558a9a3aeb985282b255fcd80bb2201c19ae54b` from upstream PR #857. +The candidate pin supplies exact `Retry-After` admission deferral, +rate-budget-derived readiness polling cadence, and endpoint-scoped structured +admission. Readiness now measures both the internal JSON-Schema judge and the +final JSON-object transport before a synthesizer can serve structured output; +an explicitly requested non-admitted model fails closed. PR #857 remains open, +so neither the candidate pin nor local runtime evidence is protected upstream +release evidence. The pin remains explicit and +immutable until the reviewed upstream change is superseded; it is not a moving +`main` reference and it is not a LineageWeave monkey patch. The Docker builder verifies that archive against its committed SHA-256 before extracting it. Runtime Python packages and every transitive dependency are installed only from `docker/contextual-orchestrator/requirements.lock` with @@ -37,13 +44,24 @@ The runtime contract is: - Multimodal synthesis excludes embedded image/base64 payloads from its textual reconciliation prompt; independent VISION worker evidence is retained instead. - A provider 4xx is reported as a failed orchestration attempt, never as a - successful empty semantic result. + successful empty semantic result. HTTP 429 becomes a bounded admission + deferral only when the positive integer `Retry-After` header exactly matches + `error.detail.retry_after_seconds`; malformed or conflicting responses fail + closed. - An empty seed model is expanded from the configured gateway `/v1/models` endpoint; embedding-only rows are not added to the chat agent pool. +- Chat Completions and Responses may constrain routing to an exact configured + endpoint identity; the selector is never forwarded to a provider and is not + applied to embeddings or deferred batch work. - A batch embedding request may omit `model`; contextual-orchestrator selects an embedding-capable model and returns its identity for subsequent batches. - `json_object`, `json_schema`, and Responses JSON formats run conduct plus synthesis. Tool requests never silently fall back to one agent. +- Asynchronous provider-readiness jobs declare the positive integer polling + cadence derived from the server's configured admission window; consumers do + not invent a polling interval. +- One candidate's bounded probe failure records that candidate as not ready; + it does not discard successful readiness evidence from other candidates. ## Consequences diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md index 2acc86cf9..f86c452bb 100644 --- a/docs/adr/0098-valkey-backed-post-content-ingestion.md +++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md @@ -32,6 +32,28 @@ placed in a stream message. permits three attempts, then records terminal `post_content_ingestion_attempt_limit`; duplicate wake-ups cannot reopen a terminal failure. A changed source digest starts a new budget. + Recovery walks the ready ledger with the deterministic + `(eligible_at, post_id)` keyset and wraps only after reaching the end. The + derived `eligible_at` is the row's existing eligibility instant: an + explicit `next_attempt_at`, `queued_at` for an initial attempt, + `queued_at + five minutes` for a retry without an explicit instant, or + `started_at + fifteen minutes` for a stale running lease. The same derived + value is used by both the due predicate and cursor ordering, so a retry that + becomes due after the cursor advanced remains ahead of that cursor. It must + not repeatedly publish only the first bounded page while later rows starve. + The cursor advances only through the contiguous successfully published + prefix; a Valkey failure leaves the first unpublished row eligible for the + next recovery cycle instead of postponing it until a full wrap. + The worker trims the Valkey stream through its consumed cursor. Producers + retain the existing approximate 1,000-entry bound so a worker outage cannot + grow the non-authoritative transport without limit; if that bound drops an + unread wake-up, fair ledger recovery republishes its row on a later page. + This cursor contract has exactly one process owner. The worker process must + acquire its PostgreSQL session advisory lease before starting any durable + consumer; a second replica fails closed before it can read or trim the + stream. Shutdown cancels and joins every consumer before releasing that + lease. Horizontal worker replication requires a successor ADR and a native + consumer-group acknowledgement contract. 4. The worker reuses the existing contextual-orchestrator client factories for VISION, structure, and embeddings. It preserves one post session and the bounded provenance metadata from `llm_context`; no raw provider call, model @@ -83,6 +105,17 @@ normalized PostgreSQL ledger is scanned and queued/stale rows are republished after the cursor is established. This prevents a restart from replaying an unbounded historical stream before processing current work. +Within one worker lifetime, the recovery keyset cursor advances by effective +eligibility across every ready queued or stale-running lease and wraps at the +end. This is publication +reachability, not a change to retry order, attempt budgets, or provider +admission. Wake-up cleanup is consumption-bound while the worker is available: +a successful batch advances the consumer cursor and then removes entries +through that cursor. During an outage, the pre-existing producer bound limits +transport growth. PostgreSQL remains authoritative, so a wake-up removed by +that bound is recovered by the advancing keyset rather than being lost behind +page one. + Lease recovery also fences completion by `attempt_count`. A worker whose 15-minute lease was reclaimed may finish after the replacement worker has started; its success, retry, or terminal failure transition is accepted only @@ -103,6 +136,14 @@ work or an unbounded HTTP request. Candidate selection and broker recovery are independent: either failure is recorded and retried on the next cycle without stopping the worker. +The bounded candidate scan uses the partial +`source_post_content_backfill_candidate_idx` on the candidate query's event-time +fallback and deterministic tie-breakers. Its partial predicate excludes drafts +and deleted rows; the query retains the shared source-context predicate. This +lets PostgreSQL stop after the requested ordered page instead of evaluating +content completeness across the whole source corpus. It does not change +eligibility or completeness semantics. + The CLI retains the same per-query bound. `--all-pages` repeats that governed producer until the current candidate set is empty; progress remains visible in the normalized job ledger after every page. Terminal failures are never reset diff --git a/docs/adr/0123-provider-error-boundary.md b/docs/adr/0123-provider-error-boundary.md index 48610ebeb..a59e64e74 100644 --- a/docs/adr/0123-provider-error-boundary.md +++ b/docs/adr/0123-provider-error-boundary.md @@ -32,6 +32,14 @@ Missing or malformed evidence remains unavailable; it is never converted into a fabricated negative result. Existing input-validation errors outside a provider boundary retain their client-actionable 422 detail. +Provider admission deferral is a narrow control exception. HTTP 503 +`no_viable_agent` and HTTP 429 `rate_limit_exceeded` become a retryable worker +signal only when the orchestrator returns the same positive integer delay in +both `Retry-After` and `error.detail.retry_after_seconds`. A missing, +malformed, or conflicting value remains an ordinary unavailable response. +This consumes the upstream contract introduced by contextual-orchestrator PR +#907 without exposing its error body to the product surface. + ## Consequences - API clients receive a safe retry/configuration action rather than provider diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 71ff03498..47e28baf2 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -28,11 +28,16 @@ provenance. 2. Dashboard requests are bounded by an inclusive event-time period. `source_post.event_occurred_at` is the primary clock and `created_at` is the explicit fallback, matching ADR 0202. The response names that clock. -3. Every count is authorization-filtered before aggregation. Event count is - the number of persisted `post_summary_event` rows for the classified, - visible posts; post count is the distinct count of those posts. Neither - substitutes for the other, and no event is invented when a summary event - row is absent. +3. Every count is authorization-filtered before aggregation. A case Event + count is the number of persisted `operations_case_milestone` rows joined by + both `post_id` and `case_kind_code` to the classified, visible cases; post + count is the distinct count of those posts. A general + `post_summary_event` is not copied into every classification on its Post. + Case kinds without an explicitly cited milestone therefore report zero + case Events. Neither count substitutes for the other, and no event is + invented when a case-specific milestone is absent. The existing composite + primary/foreign keys keep this relation in third normal form, while the + case-kind/time index keeps aggregation independent of one Post hot key. Analysis-pending and ingestion-failed post counts are disjoint: a failed current job is shown as retryable failure, never hidden inside the pending count or interpreted as a negative classification. @@ -204,9 +209,21 @@ treated as a negative case. rendered desktop and narrow layouts. - `scripts/accept_operations_dashboard_runtime.sh` fails closed on the exact orchestrator image revision, performs the explicit structured-readiness - refresh only after operator opt-in, verifies one normalized preferred - candidate and a positive grounded-case aggregate delta, then exercises the - authenticated Dashboard API and rendered UI without printing source rows. + refresh only after operator opt-in, and polls that asynchronous job only at + the positive integer cadence declared by the orchestrator's admission + contract. A missing or malformed cadence is unavailable, not permission to + invent a local interval. This response field is owned by + `ContextualWisdomLab/contextual-orchestrator` PR #907; LineageWeave consumes + it without duplicating the rate-window calculation. The runner treats the + durable content ledger as resumable rather than assuming an empty queue. It + binds evidence to the exact worker image revision and container start instant, + then accepts either an eligible, current-source-digest grounded analysis + written by that deployment or observes both analysis and grounded aggregate + counts advance while an eligible queued/running item already exists. It never + resets, fabricates, or re-enqueues work for acceptance, and it fails closed + when neither form of evidence exists. Counts are distinct by post and remain + aggregate-only. The runner then exercises the authenticated Dashboard API and + rendered UI without printing source rows. The same operator-declared run invokes `scripts/k6_operations_dashboard.js` with explicit VUs and duration; it observes Dashboard reads only, defines no performance threshold, and keeps its summary outside the repository. The diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md index ad266a6da..778a0235b 100644 --- a/docs/adr/0210-temporal-topic-context-influence-dashboard.md +++ b/docs/adr/0210-temporal-topic-context-influence-dashboard.md @@ -1,7 +1,8 @@ # ADR 0210: TEPP temporal topics and fast-mlsirm context influence - Status: Accepted -- Implementation maturity: consumer projection candidate; accepted producer result unavailable +- Implementation maturity: consumer projection and fail-closed producer delivery candidate; + accepted upstream numerical result unavailable - Date: 2026-08-25 - Depends on: ADR 0132 (TEPP topic-lineage boundary), ADR 0206 (operations Dashboard) - Upstream authorities: TEPP ADR 0012; fast-mlsirm ADR 0002 and ADR 0007 @@ -137,6 +138,66 @@ renormalizing scores. The frontend renders an exact-value table alongside the temporal topic view, uses text/pattern as well as color for topic state, and supports keyboard, touch, reduced motion, narrow viewports, and screen readers. +The durable worker submits only from the accepted, normalized +`tepp.topic_context_posterior.v1` projection. Its TEPP run identity, immutable +source snapshot, knowledge cutoff, producer-contract version, posterior-draw +identity, and upstream artifact digest are required fields; its coordinates, +memberships, and provenance must be complete. The older +`analysis_run_topic_lineage_result` stores a distinct topic-identity/CHRONOS +envelope with a LineageWeave-computed envelope digest, while +`analysis_run_tepp_receipt` records calibrated-measurement transport +acceptance. Neither is evidence for this posterior projection and their +identifiers or digests must not be equated with it. The request contains every posterior draw and every source-derived +business-unit, PU, team, and person membership present in the run. The run +must cover all four dimensions, while an individual post may belong only to +the dimensions supported by its evidence and may retain several time-valid +slices for one context. It is content-addressed before +the database lease is released. The worker admits only a complete Cartesian +set of post-membership-topic rows whose request, TEPP run, snapshot, cutoff, +membership fingerprint, producer revision, convergence, identification, +backend parity, and artifact digest all match. It recomputes the request digest +inside the persistence transaction so a changed input cannot receive a stale +result. Provider work holds neither a database transaction nor a pool lease. +Incomplete older evidence is scanned past rather than pinning the queue. An +exact remote `Retry-After` requeues at that admitted instant; all other +failures require an explicit operator requeue after their cause is corrected, +so the worker never invents a retry interval. +The deployment declares request and lease timeout seconds together. The lease +must strictly exceed the request timeout so the operator-declared difference +remains available for result validation and persistence. A running row becomes +claimable only after that recorded lease expiry. Incomplete input moves to a +typed awaiting-evidence state and is woken only by a new accepted topic model, +analysis cutoff/snapshot binding, coordinate, definition, or +membership event. Source snapshots themselves are immutable under ADR 0018. +If evidence changes during computation, the stale lease is released immediately +and the next claim rebuilds the request. Invalid optional influence transport +configuration disables only this consumer; analysis, content, and Ask work +continues. The deployment also declares the positive poll interval. A transient +database claim failure waits that exact interval rather than terminating the +shared durable-worker task. +Each claim also receives a unique database lease token. Success, failure, +remote defer, and changed-input release update a running row only when that +exact token still owns it, so safety does not rely only on the process-wide +advisory lock. + +LineageWeave sends the request and membership design as base64-encoded raw JSON +artifact bytes with the SHA-256 of those exact bytes. The producer verifies and +parses those bytes, then echoes both LineageWeave-owned opaque identities +unchanged. The producer returns its result through the same raw-byte envelope. +LineageWeave verifies the result bytes before UTF-8 decoding or JSON parsing and +never reserializes producer floats to verify any digest. This avoids inventing +a canonical-JSON dialect or depending on Python and Rust float formatting +coincidence; adopting RFC 8785 remains unavailable until both deployed sides +implement and pass the same official vectors. + +This delivery path does not make the feature available by itself. The +configured owner endpoint must implement the domain-neutral continuous +posterior case-deletion estimand in Rust. fast-mlsirm's crossed weighted +multiple-membership MAP contract supplies the reusable membership design and +identification boundary; its binary response kernel is not applied to TEPP +coordinates. Until the continuous result contract is released, the job remains +unconfigured or records a bounded failure and the Dashboard stays unavailable. + The LineageWeave consumer projection is allowed to land before activation. In that state, it reports which exact producer contract is not persisted and returns no topic, influence, rank, or fallback value. An accepted result is diff --git a/docs/adr/0215-global-ask-public-claim-verification.md b/docs/adr/0215-global-ask-public-claim-verification.md index ca70f566e..c3e0eda50 100644 --- a/docs/adr/0215-global-ask-public-claim-verification.md +++ b/docs/adr/0215-global-ask-public-claim-verification.md @@ -31,6 +31,11 @@ carried by a cited public source. Private sources, Keyman/person facts, raw source hints, source bodies, TEPP artifacts, fast-mlsirm artifacts, prompts, credentials, and uncited facts never form a public query. +ADR 0269 strengthens admission: the production queue now requires a persisted, +PROV-O-bound public-claim envelope for an exact cited post. Question-token +overlap is retained only as legacy library compatibility and is not a runtime +egress decision. + SearXNG retrieves at most five bounded snippets for at most four claims. Result URLs must be HTTP(S), must not be search pages, localhost, `.local`, or literal non-global addresses, and are never fetched by LineageWeave. The untrusted diff --git a/docs/adr/0219-tepp-terminal-result-lifecycle.md b/docs/adr/0219-tepp-terminal-result-lifecycle.md index e100ab858..1c67fcfe5 100644 --- a/docs/adr/0219-tepp-terminal-result-lifecycle.md +++ b/docs/adr/0219-tepp-terminal-result-lifecycle.md @@ -1,8 +1,8 @@ # ADR 0219 — Persist TEPP acceptance and consume terminal results -**Decision status:** Accepted on this active PR; not protected-main truth until merge -**Date:** 2026-08-26 -**Depends on:** ADR 0022, ADR 0023, ADR 0204; TEPP PR #157 +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-26 +**Depends on:** ADR 0022, ADR 0023, ADR 0204; TEPP PR #157 **Refs:** LineageWeave issue #277; TEPP issues #156 and #249 ## Context diff --git a/docs/adr/0237-accelerator-runtime-service-boundary.md b/docs/adr/0237-accelerator-runtime-service-boundary.md index 621a61382..ba5a37a35 100644 --- a/docs/adr/0237-accelerator-runtime-service-boundary.md +++ b/docs/adr/0237-accelerator-runtime-service-boundary.md @@ -1,7 +1,7 @@ # ADR 0237 — Accelerator runtimes stay behind owning service contracts -**Decision status:** Accepted -**Date:** 2026-08-26 +**Decision status:** Accepted +**Date:** 2026-08-26 **Related:** ADR 0076, ADR 0083, ADR 0208 ## Context diff --git a/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md b/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md index 1995686d4..8e361cdb0 100644 --- a/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md +++ b/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md @@ -1,7 +1,7 @@ # ADR 0251: I/O Psychology Cognitive, Affective, and Behavioral Ontology and Semantic Layer -**Status:** Accepted -**Date:** 2026-08-27 +**Status:** Accepted +**Date:** 2026-08-27 **Deciders:** LineageWeave Architecture, ContextualWisdomLab Core --- diff --git a/docs/adr/0257-onet-occupation-rating-observation-store.md b/docs/adr/0257-onet-occupation-rating-observation-store.md index 8e00e13a8..c00f9fe90 100644 --- a/docs/adr/0257-onet-occupation-rating-observation-store.md +++ b/docs/adr/0257-onet-occupation-rating-observation-store.md @@ -1,7 +1,7 @@ # ADR 0257: O*NET occupation-rating observation store -**Status:** Accepted -**Date:** 2026-08-27 +**Status:** Accepted +**Date:** 2026-08-27 **Extends:** ADR 0166, ADR 0255, ADR 0256 ## Context diff --git a/docs/adr/0263-authorized-job-architecture-import.md b/docs/adr/0263-authorized-job-architecture-import.md index 9802ed1b4..4b3edb463 100644 --- a/docs/adr/0263-authorized-job-architecture-import.md +++ b/docs/adr/0263-authorized-job-architecture-import.md @@ -1,7 +1,7 @@ # ADR 0263: Authorized job-family and job-series snapshot import -**Status:** Accepted -**Date:** 2026-08-27 +**Status:** Accepted +**Date:** 2026-08-27 **Extends:** ADR 0001, ADR 0065, ADR 0248, ADR 0252 ## Context diff --git a/docs/adr/0269-persisted-public-claim-admission.md b/docs/adr/0269-persisted-public-claim-admission.md new file mode 100644 index 000000000..ded6de975 --- /dev/null +++ b/docs/adr/0269-persisted-public-claim-admission.md @@ -0,0 +1,55 @@ +# ADR 0269: Persist public-claim admission before external verification + +## Status + +Accepted + +## Context + +ADR 0215 defines opt-in public verification and keeps external evidence +separate from internal authority. Its first implementation nominated semantic +facts by token overlap with the question. Token overlap is neither provenance +nor a governed claim-admission decision, and it can change when wording changes. + +The abandoned draft PR #679 proposed replacing that implementation wholesale. +The current Global Ask queue, cutoff behavior, authorization scope, SearXNG +validation, and contextual-orchestrator verifier have since evolved and remain +authoritative. Only the persisted admission boundary is still missing. + +## Decision + +`public_claim_envelope` stores one bounded claim kind, exact claim text, source +post, PROV-O `prov:wasDerivedFrom` assertion, and egress decision. The evidence +resource must bind to that same source post. Only organization presence, public +event, and public relationship kinds are admitted; person, Keyman, measurement, +prompt, and source-body payloads have no storage code. + +Production Global Ask loads at most four envelopes whose source post is both +public and cited in the completed answer. The per-question opt-in remains the +durable consent boundary. A cutoff excludes envelopes or source posts created +after that cutoff. Changing a post from public revokes egress eligibility. + +The persisted envelope supplies candidates to the existing ADR 0215 verifier. +It does not replace SearXNG URL validation, contextual-orchestrator adjudication, +or the distinction between external URLs and internal post citations. No claim +is inferred from question-token overlap in the production path. When no current, +authorized envelope exists, verification reports no public claims and performs +no external request. + +## Consequences + +- Public egress admission is stable, reviewable, and provenance-bearing. +- Existing verification transport and outcome contracts remain unchanged. +- A producer must persist a governed envelope before a claim becomes eligible; + absence stays unavailable rather than being repaired heuristically. +- Draft PR #679 remains historical evidence for the missing boundary and is not + merged wholesale over the current semantic stack. + +## References + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A +large-scale dataset for fact extraction and verification. In *Proceedings of +NAACL-HLT 2018* (pp. 809–819). https://doi.org/10.18653/v1/N18-1074 diff --git a/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md b/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md new file mode 100644 index 000000000..70548e445 --- /dev/null +++ b/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md @@ -0,0 +1,61 @@ +# ADR 0270: Digest-bound project-journey temporal evidence + +- Status: Accepted on this stacked branch; not protected-main truth until merge +- Date: 2026-08-28 +- Depends on: ADR 0132, ADR 0231, ADR 0243; TEPP PR #291 +- Figma file ID: `SBpgot7uTvMxEaxUwvoc0S` + +## Context + +TEPP PR #291 publishes canonical JSON and GraphML for bounded Allen interval- +consistency results. The artifact binds a run, snapshot, exact input digest, +ordered event pair, observed/derived status, and supporting assertion ordinals. +It deliberately does not claim that temporal order is a causal transition, +project predecessor, or business-process branch. + +LineageWeave already admits related predecessor paths through authorized +`post_lineage_edge` evidence. Promoting every temporally ordered pair to a +project journey would contradict PRD-FR-5E and ADR 0243. + +## Decision + +LineageWeave accepts only canonical artifact bytes whose SHA-256, run, +snapshot, and exact input digest match caller-computed expected values. The +remote run must also match a persisted terminal TEPP result. Metadata, +relations, elementary Allen kinds, and support ordinals persist in normalized +tables. + +Every admitted temporal pair must already be an exact `post_lineage_edge`. +The database foreign key enforces that boundary. Temporal evidence may +corroborate the time order of an existing related-history path; it never +creates a predecessor, branch, responsibility handoff, or causal transition. +A branch is visible only when the independently admitted lineage graph already +contains that topology. A transition still requires its separately governed +observed business or responsibility evidence. + +The Project History API attaches the newest immutable temporal evidence whose +analysis cutoff does not exceed the requested view cutoff to the corresponding +visible edge after ABAC selects both endpoints. The customer UI says what the user can do next—open the supporting +records and compare dates—and never names the calculation module. + +GraphML is an equivalent provider export, not the ingestion authority. The +canonical typed JSON is the sole admitted payload so two representations +cannot diverge inside the database. + +## Consequences + +- Exact temporal consistency becomes durable and auditable without duplicating + mathematical reasoning in Python. +- A valid artifact containing a pair absent from Event Lineage fails closed at + the foreign-key boundary and rolls back its transaction. +- A future contract that explicitly carries business predecessor or transition + semantics requires a new ADR; this artifact cannot be reinterpreted later. + +## References + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. +*Communications of the ACM, 26*(11), 832–843. +https://doi.org/10.1145/182.358434 + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. +https://www.w3.org/TR/prov-o/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 44bd443fd..649192595 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,12 +21,14 @@ decision from them. | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md), [0256](0256-evidence-bearing-voice-combinations.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) | +| Persisted public-claim admission | [0269](0269-persisted-public-claim-admission.md) | | [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | | [`GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md`](../doctoring/GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md) | [0217](0217-evidence-constrained-semantic-query-rewrite.md) | | [`MCP_GLOBAL_ASK_REFERENCES.md`](../doctoring/MCP_GLOBAL_ASK_REFERENCES.md) | [0218](0218-current-contract-mcp-global-ask.md) | | [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`operability/mcp-concurrency-evidence.md`](../operability/mcp-concurrency-evidence.md) | [0218](0218-current-contract-mcp-global-ask.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | +| Project-journey temporal evidence | [0270](0270-digest-bound-project-journey-temporal-evidence.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | | [`WORKER_FUNCTION_TAXONOMY_REFERENCES.md`](../doctoring/WORKER_FUNCTION_TAXONOMY_REFERENCES.md) | [0232](0232-worker-function-taxonomy-in-the-published-ontology.md) | diff --git a/docs/manuals/operations-manual.md b/docs/manuals/operations-manual.md index 22d525158..c30c9b3a1 100644 --- a/docs/manuals/operations-manual.md +++ b/docs/manuals/operations-manual.md @@ -52,6 +52,34 @@ the canonical stack under a different project name. Host/Origin, request-size, and k6-evidenced quota values described in the [MCP manual](mcp-manual.md). +For authenticated runtime acceptance, start the exact-revision stack with the +MCP profile and declare separate provider-probe and readiness-observation +budgets. `ORCHESTRATOR_PROBE_TIMEOUT_SECONDS` accepts 0.1 through 30 seconds; +`ORCHESTRATOR_READINESS_TIMEOUT_SECONDS` is the positive-integer wall-clock +budget for the asynchronous job. The acceptance runner reads the cached agent +catalog inside the orchestrator container, probes only active agents belonging +to the configured gateway for the structured workflow used by content +analysis, and fails closed if no such agent becomes ready. While the job is +pending, the runner accepts only the positive integer polling cadence declared +by contextual-orchestrator (upstream PR #907) and never substitutes a local +polling interval. + +Declare `OPERATIONS_CASE_ACCEPTANCE_TIMEOUT_SECONDS` and +`OPERATIONS_CASE_POLL_SECONDS` as separate positive-integer observation inputs. +The runner does not enqueue a demonstration record or assume a fresh ledger. It +first accepts aggregate grounded evidence produced since the exact worker +container started. If none exists yet, an eligible queued/running record with no +current-source-digest analysis must already be present; the runner then waits +for both deployment-bound analysis and grounded aggregate counts to advance. +It fails closed when neither path is available. Source rows and record +identifiers remain inside the database and are never printed. + +The 2026-08-26 diagnostic run supplied `MCP_RATE_LIMIT_REQUESTS=1000` and +`MCP_RATE_LIMIT_WINDOW_SECONDS=60` only to its acceptance invocation. Those +observed inputs are neither source defaults nor a production capacity SLO; +repeat k6 measurement in the target deployment before selecting production +quota values. + ## Durable asynchronous work The API enqueues Ask and content-analysis work; workers perform provider calls diff --git a/docs/product-requirements.md b/docs/product-requirements.md index a911445b7..b02da20be 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -386,8 +386,9 @@ stale evidence from a previously opened post. ### PRD-FR-5A — Opt-in public claim verification - Persist an explicit per-question opt-in before any external search begins. -- Nominate only cited, public semantic/KG facts; source bodies, private facts, - personal facts, and measurement outputs never become external queries. +- Admit only persisted, provenance-bearing claims for exact cited public posts; + source bodies, private facts, personal facts, measurement outputs, and claims + nominated from question-token overlap never become external queries (ADR 0269). - Retrieve bounded public evidence through SearXNG and adjudicate through contextual-orchestrator's verification mode. - Report supported, refuted, and not-enough-information outcomes without @@ -398,6 +399,8 @@ stale evidence from a previously opened post. Acceptance: leaving the control off causes no public request; hidden or uncited facts cause no public request; unavailable services fail closed; and each displayed public judgment retains its originating internal evidence IDs. +An absent or unauthorized persisted envelope performs no external request and +reports that no public claim is available rather than fabricating admission. ### PRD-FR-5B — Knowledge-cutoff Global Ask @@ -453,9 +456,15 @@ comes from the cited answer rather than frontend inference. - Persist closed-vocabulary milestones for claim, rebid, and handover. Report open, resolved, and evidence-missing counts and elapsed time only between two observed endpoints; never invent an endpoint or delay threshold. +- Count Events per work type only from those cited, normalized milestones. + Never copy a Post's general summary Events into each case classification; + a case with no supported milestone reports zero Events. - Present project-specific journeys only from accepted evidence-bearing predecessor and branch relations. A timestamp sort may be labeled observed events, but never promoted to a journey. +- Attach digest-bound interval-consistency evidence only to an already + admitted predecessor edge. Temporal order alone never creates a predecessor, + branch, responsibility handoff, or causal transition (ADR 0270). Acceptance: every populated fact, lifecycle endpoint, membership, and journey event opens an authorized evidence post; an incomplete provenance chain fails diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 98a38e15a..f85b62999 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3,9 +3,14 @@ > Current rebuild overlay: 2026-08-28 KST. Protected `main` is > `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #640 is a ready-for-review > current-main semantic rebuild at `f0bc98eef238b7a03d4227ab909c8de296041f36`. -> PR #778 remains remotely at `3d38f48dd3ca7e939b60f80f34ca61260c377818`; -> its locally tested restack candidate before this documentation-only update -> was `572ef39bf0f31882a8b1cb920f69b66d38176dab`. The open queue has 14 PRs: +> PR #778 is remotely published at +> `b87b186dd7213dc59d8e933e7d8c3f330598470f`; PR #781's last remote +> exact-head evidence before this overlay is +> `760d05896f96e5ce7fb9df0e4b62369448913fbd` and remains candidate-only. +> Its contextual-orchestrator runtime is pinned to open upstream PR #857 exact +> `3558a9a3aeb985282b255fcd80bb2201c19ae54b`; this candidate is not +> protected-main evidence. +> The open queue has 14 PRs: > #783, #782, #781, #780, #778, #774, #772, #771, #770, #702, #679, #672, > #667, and #640; #702/#679/#672/#667 remain drafts. Local candidate tests do > not transfer to the remote PR head or protected `main`. Exact-head Compose, @@ -288,15 +293,15 @@ explicit unavailable state, not a reason to infer mappings from labels. | Requirement | Evidence contract | Delivery state | |---|---|---| -| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate implementation; authenticated runtime acceptance pending | -| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate implementation; corpus backfill pending | +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; case-specific normalized milestones | Candidate implementation counts only cited claim milestones instead of duplicating every Post summary Event; authenticated runtime acceptance pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts and case-specific normalized milestones | Candidate implementation counts only cited rebid/handover milestones; corpus backfill pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision | | Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API and ordered journey UI implemented; authenticated runtime acceptance pending | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | | TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet | -| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | Product/technical contract is protected on `main`; neither required Rust CPU/GPU producer envelope is shipped, so the Dashboard surface remains unavailable (ADR 0208: no local Python substitute) | +| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | The stacked successor adds a durable, short-transaction producer request, exact accepted TEPP posterior run/snapshot/cutoff/artifact binding, four-level source-membership admission, complete-result validation, and normalized persistence. It does not misbind the older topic-lineage envelope or calibrated-measurement receipt to this scientifically distinct posterior projection. Incomplete evidence is stored until a new evidence event, expired work follows an operator-declared lease that strictly exceeds the request timeout, changed input automatically produces a fresh request, and exact request, membership, and result artifact bytes are digest-verified before parsing. The Dashboard remains unavailable until TEPP publishes the full posterior/membership artifact and fast-mlsirm publishes the domain-neutral continuous-posterior Rust result endpoint; the crossed weighted MAP binary kernel is not misapplied and no local Python substitute exists | ### Technical contract and flow @@ -654,6 +659,7 @@ this file per §3.5 of the prior snapshot). | Gap | Current evidence | Acceptance requirement | | --- | --- | --- | | Protected release | 12 open PRs at snapshot, all targeting `main` with normal auto-merge enabled. None has the required independent approval, and running checks on #631/#632/#663 are not treated as blockers for safe work on other PRs. #666's merge into the non-default #663 branch is not protected-main delivery | Terminal exact-head checks, no unresolved threads, two independent approvals including last-push approval, protected squash-merge SHA | +| Orchestrator admission and readiness | LineageWeave PR #781 consumes contextual-orchestrator PR #907's exact positive-integer `Retry-After`/detail agreement and admission-derived readiness polling cadence. Both #907 and its parent #857 are open stacks, so the pin is candidate integration evidence only | Merge #857 then #907 through their protected gates, repin the protected upstream merge commit, rebuild the exact LineageWeave images, and prove structured readiness plus deferred backfill recovery without exhausting the declared admission window | | CI queue release latency | Two Tests runs for already merged PRs occupied the available runner slots while 54 newer runs remained queued. Manual cancellation released the stale work, but the central close workflow was itself queued behind those runs. #634 merged into #631's non-default branch and reuses the repository's existing per-PR concurrency group so a jobless close event can cancel obsolete Tests work before runner allocation; this is not protected-main delivery | Merge #631 through its refreshed protected gate; close a synthetic PR while its Tests run is active and verify the old run becomes cancelled, the close-event jobs remain skipped, and a newer exact-head run starts without manual intervention | | Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. Authorized-corpus backfill acceptance remains unavailable | Perform authenticated authorized-corpus acceptance with aggregate evidence and retain fail-closed no-match behavior | | Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push | @@ -830,7 +836,7 @@ post-merge reruns (not transferable evidence for later heads): | ---: | --- | --- | | #643 | Shared StatusNotice (ADR 0220): success/unavailable/retry states, WorkspaceCalendar auth-unavailable copy, 5-locale i18n; CI Full suite 22m54s green | ADR 0220 | | #644 | Native workspace surface split: 9 conditionally rendered components as lazy() dynamic imports behind a SurfaceBoundary error boundary; build emits 9 chunks (1.5-37 kB), main bundle 543 kB; 470 frontend tests, tsc, Storybook green | — | -| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up) | ADR 0243 | +| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up). ADR 0270 successor work admits digest-bound interval evidence only for existing lineage edges; it does not promote time order to a business transition. | ADR 0243, ADR 0270 | | #763 | Live-PostgreSQL A→B→A Voice history validation (ADR 0252) proving effective_from/effective_to interval replacement across repeated primary-Voice imports | ADR 0252 | | #764 | Test-only coverage lift: observability 78%→96%, post_summary 77%→89%, claim_verification 86%→99%; package line coverage 93.5%→95% (484→371 missing); 1651 Python tests green | — | | #761 | Temporal imported-primary Voice history (ADR 0252): migration 0243 (`effective_to` + GiST primary-period exclusion + synchronize trigger), refined 0237 `least()` effective_from backfill, `effective_from/effective_to` dataclass/export + `coalesce($2,$3)` cutoff predicate. Completes the half-shipped main layer that queried `voice.effective_to` against a missing column. CI Full suite 19m13s green | ADR 0252 | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 1413a4241..adba73286 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,7 +6,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| | `Customer Master/Linking guidance` | Before linking a customer, compare the source identifier with related posts and organization evidence. `Desktop` and `Narrow` keep the same next action without exposing implementation terms. | `workspace-destination-intro`, `CustomerLinkingGuidance` | -| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, `ConcurrentLoading`, `LoadError`, and `VoiceSummaryLoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, one accessible announcement for parallel loading, whole-dashboard transport failure, and independently retryable voice-summary failure. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` | +| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `TopicInfluenceDark`, `TopicInfluenceReducedMotion`, `TopicInfluenceKeyboard`, and `TopicInfluenceTouch` cover the ADR 0210 presentation and interaction modes. `EvidenceReady`, `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, `ConcurrentLoading`, `LoadError`, and `VoiceSummaryLoadError` cover unavailable evidence, mobile, scoped-empty, explicit evidence absence, analysis pending, retryable failure, one accessible parallel-loading announcement, whole-dashboard request failure, and independently retryable voice-summary failure. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` | | `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence, source post, or persisted related public source. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` | | `Ask Agent/Knowledge cutoff` | Ask with public verification enabled, then follow the displayed next action when no claim is eligible. `NoEligiblePublicClaim` and `NoEligiblePublicClaimNarrow` render the full result panel at desktop and mobile widths. | `ask-delivery`, `AskAgentPanel` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | diff --git a/frontend/e2e/runtime-ask-evidence.spec.ts b/frontend/e2e/runtime-ask-evidence.spec.ts new file mode 100644 index 000000000..d47ff9e1d --- /dev/null +++ b/frontend/e2e/runtime-ask-evidence.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; + +function jwtExpiry(accessToken: string): number { + const segments = accessToken.split("."); + if (segments.length !== 3) throw new Error("runtime access token must be a JWT"); + const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf8")) as { + exp?: unknown; + }; + if (!Number.isInteger(payload.exp)) throw new Error("runtime access token must carry exp"); + return payload.exp as number; +} + +test("asks one operator-supplied question and opens cited evidence", async ({ page }, testInfo) => { + const accessToken = process.env.LINEAGEWEAVE_ACCESS_TOKEN; + const issuer = process.env.LINEAGEWEAVE_OIDC_ISSUER; + const clientId = process.env.LINEAGEWEAVE_OIDC_CLIENT_ID; + const question = process.env.LINEAGEWEAVE_RUNTIME_ASK_QUESTION?.trim(); + const timeoutSeconds = Number(process.env.LINEAGEWEAVE_RUNTIME_ASK_TIMEOUT_SECONDS); + const screenshotPath = + testInfo.project.name === "chromium-mobile" + ? process.env.ASK_SCREENSHOT_MOBILE_PATH + : process.env.ASK_SCREENSHOT_DESKTOP_PATH; + if ( + !accessToken || + !issuer || + !clientId || + !question || + !screenshotPath || + !Number.isInteger(timeoutSeconds) || + timeoutSeconds <= 0 + ) { + throw new Error("runtime Ask token, OIDC, question, timeout, and screenshot environment is required"); + } + test.setTimeout(timeoutSeconds * 1000); + if (jwtExpiry(accessToken) - Math.floor(Date.now() / 1000) < timeoutSeconds) { + throw new Error("runtime Ask access token expires before the declared observation budget"); + } + + await page.addInitScript( + ({ token, storageKey, expiresAt }) => { + localStorage.setItem( + storageKey, + JSON.stringify({ + access_token: token, + token_type: "Bearer", + expires_at: expiresAt, + profile: { sub: "runtime-acceptance" }, + scope: "openid", + }), + ); + }, + { token: accessToken, storageKey: `oidc.user:${issuer}:${clientId}`, expiresAt: jwtExpiry(accessToken) }, + ); + await page.goto("/"); + await page.locator(".language-switcher select").selectOption("en"); + await page.getByRole("button", { name: "Ask Agent" }).click(); + await page.getByRole("textbox", { name: "Ask a question" }).fill(question); + await page.getByRole("button", { name: "Ask", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible({ + timeout: timeoutSeconds * 1000, + }); + await expect(page.getByRole("heading", { name: "Cited posts" })).toBeVisible(); + await page.screenshot({ path: screenshotPath, fullPage: true }); + + await page.getByRole("button", { name: "View evidence" }).first().click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Close evidence panel" }).click(); + await expect(dialog).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Cited posts" })).toBeVisible(); +}); diff --git a/frontend/e2e/runtime-operations-dashboard.spec.ts b/frontend/e2e/runtime-operations-dashboard.spec.ts index 7b25531d8..7a74ef77a 100644 --- a/frontend/e2e/runtime-operations-dashboard.spec.ts +++ b/frontend/e2e/runtime-operations-dashboard.spec.ts @@ -51,6 +51,13 @@ test("renders the authenticated operations Dashboard with grounded cases", async } if (requireGroundedCase) { await expect(page.locator(".dashboard-case-card").first()).toBeVisible(); + const evidenceAction = page.locator(".dashboard-case-card button").first(); + await expect(evidenceAction).toBeVisible(); + await evidenceAction.click(); + const evidenceDialog = page.getByRole("dialog"); + await expect(evidenceDialog).toBeVisible(); + await evidenceDialog.getByRole("button", { name: "Close" }).click(); + await expect(evidenceDialog).not.toBeVisible(); } await page.screenshot({ path: screenshotPath, fullPage: true }); diff --git a/frontend/src/App.css b/frontend/src/App.css index 482e1a723..15c05af8f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1184,7 +1184,7 @@ @media (max-width: 768px) { /* Phone Breakpoint (<768px) */ - + .workspace-gnb { overflow-x: auto; overscroll-behavior-inline: contain; @@ -1205,7 +1205,7 @@ .app-header { padding: 0 1rem; } - + .app-footer { flex-direction: column; align-items: flex-start; @@ -1502,6 +1502,29 @@ .dashboard-journey button { display: grid; gap: 0.25rem; min-width: 10rem; min-height: var(--size-control-min); padding: 0.75rem; border: 1px solid var(--color-border); background: var(--color-background); color: var(--color-text-heading); text-align: left; } .dashboard-journey time { color: var(--color-text); font-size: 0.75rem; } +.dashboard-topic-table-scroll { + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.dashboard-topic-table-scroll table { + width: 100%; + border-collapse: collapse; +} + +.dashboard-topic-table-scroll th, +.dashboard-topic-table-scroll td { + padding: var(--space-control-gap); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: top; +} + +.dashboard-topic-table-scroll .btn-link { + min-height: var(--size-touch-target); +} + .dashboard-case-card { display: flex; flex-direction: column; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 382edcb30..80f02c90e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3483,7 +3483,7 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument(); const list = screen.getByRole("list", { name: "Analysis runs" }); expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); - expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); + expect(list).toHaveTextContent("Calibrated event measurement · Failed · Demo Corp"); expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp"); expect(list).toHaveTextContent( "Open this run to see why it failed, then retry with the latest available records.", @@ -3576,15 +3576,15 @@ describe("App, authenticated", () => { await userEvent.click( screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: Calibrated event measurement · Failed · Demo Corp", }), ); expect( - await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), + await screen.findByRole("heading", { name: "Calibrated event measurement · Failed · Demo Corp" }), ).toBeInTheDocument(); const teppHistory = screen.getByRole("list", { name: "Analysis run status history" }); expect(teppHistory).toHaveTextContent("Failed 2026-01-12 12:37 · tepp_not_available"); - expect(screen.getByText(/cutoff corpus TEPP would measure/i)).toBeInTheDocument(); + expect(screen.getByText(/selected for calibrated measurement/i)).toBeInTheDocument(); expect(teppHistory).not.toHaveTextContent("Succeeded"); }); @@ -3664,7 +3664,7 @@ describe("App, authenticated", () => { name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", }); const teppButton = screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: Calibrated event measurement · Failed · Demo Corp", }); expect(lineageButton).toHaveTextContent( "Open this run to see why it failed, then retry reconstruction from a current snapshot.", @@ -3972,17 +3972,17 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + name: "Open analysis run: Calibrated event measurement · Pending · Demo Corp", }), ); expect( - await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + await screen.findByText("These posts will be included when calibrated measurement finishes."), ).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/were included in this calibrated measurement result/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Start TEPP measurement" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start calibrated measurement" })).toBeInTheDocument(); }); it("starts a pending TEPP run through tepp_client and does not invent a theta", async () => { @@ -3991,12 +3991,12 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + name: "Open analysis run: Calibrated event measurement · Pending · Demo Corp", }), ); - await userEvent.click(screen.getByRole("button", { name: "Start TEPP measurement" })); + await userEvent.click(screen.getByRole("button", { name: "Start calibrated measurement" })); expect( - await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), + await screen.findByRole("heading", { name: "Calibrated event measurement · Failed · Demo Corp" }), ).toBeInTheDocument(); expect(screen.getByText(/tepp_not_available/)).toBeInTheDocument(); expect(screen.queryByText(/theta/i)).not.toBeInTheDocument(); @@ -4013,16 +4013,16 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: Calibrated event measurement · Failed · Demo Corp", }), ); expect( await screen.findByText( - "Connect a TEPP transport from this Failed row. Request a lineage reconstruction does not invent a measurement.", + "Review the failure details, confirm the selected posts and cutoff, then start a new calibrated measurement.", ), ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Request a new TEPP measurement" })).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "TEPP measurement · Pending · Demo Corp" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Calibrated event measurement · Pending · Demo Corp" })).not.toBeInTheDocument(); expect( fetchMock.mock.calls.some( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", @@ -4036,11 +4036,11 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp", + name: "Open analysis run: Calibrated event measurement · Succeeded · Demo Corp", }), ); expect( - await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), + await screen.findByText("These posts were included in this calibrated measurement result."), ).toBeInTheDocument(); expect(screen.getByLabelText("Measurement request accepted")).toHaveTextContent( "Refresh this run to check whether results are ready.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e702e3638..6dfd1bc69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2904,7 +2904,13 @@ function PostDetailPopup({ } function analysisRunCaption(run: AnalysisRun): string { - return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] + const customerKindLabel = { + analysis_run_lineage: "Lineage reconstruction", + analysis_run_tepp: "Calibrated event measurement", + analysis_run_topic_lineage: "Time-based topic analysis", + analysis_run_report: "Period report", + }[run.run_kind_code]; + return [customerKindLabel ? t(customerKindLabel) : null, run.status_label, run.scope_entity_name ?? run.scope_kind_label] .filter(Boolean) .join(" · "); } @@ -2922,13 +2928,13 @@ function analysisRunNextAction(run: AnalysisRun): string | null { case "analysis_status_pending": switch (run.run_kind_code) { case "analysis_run_lineage": - return "Open this run, then start reconstruction. Reconstruction has not started yet."; + return t("Open this run, then start reconstruction. Reconstruction has not started yet."); case "analysis_run_tepp": - return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result."; + return t("Open this run to confirm the posts included in measurement, then start it."); case "analysis_run_topic_lineage": - return "Open this run to confirm which posts TEPP will thread into topic lineage. Topic-lineage analysis has not started yet — this is not a calibrated topic result."; + return t("Open this run to confirm the posts and time period included in topic analysis, then start it."); case "analysis_run_report": - return "Open this run to confirm which posts the period report will use. The report has not been built yet."; + return t("Open this run to confirm which posts the period report will use. The report has not been built yet."); default: { const unexpected: never = run.run_kind_code; return unexpected; @@ -2937,20 +2943,20 @@ function analysisRunNextAction(run: AnalysisRun): string | null { case "analysis_status_failed": switch (run.run_kind_code) { case "analysis_run_tepp": - return "Open this run to see why it failed, then retry with the latest available records."; + return t("Open this run to see why it failed, then retry with the latest available records."); case "analysis_run_topic_lineage": - return "Open this run to see why it failed, then retry with the latest available records."; + return t("Open this run to see why it failed, then retry with the latest available records."); case "analysis_run_lineage": - return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; + return t("Open this run to see why it failed, then retry reconstruction from a current snapshot."); case "analysis_run_report": - return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + return t("Open this run to see why it failed, then rebuild the period report from a current snapshot."); default: { const unexpected: never = run.run_kind_code; return unexpected; } } case "analysis_status_running": - return "Refresh this run. Start already queued the work on the durable outbox."; + return t("Refresh this run. Start already queued the work on the durable outbox."); case "analysis_status_succeeded": case "analysis_status_cancelled": case null: @@ -2966,32 +2972,26 @@ function analysisRunNextAction(run: AnalysisRun): string | null { * Empty-corpus copy that tells the operator what to do next. */ function analysisRunEmptyPostsHint(run: AnalysisRun): string { + let analysis: string; switch (run.run_kind_code) { case "analysis_run_tepp": - return ( - "No posts were available at this cutoff for TEPP to measure. " + - "Open a later run or retry after a newer snapshot is available." - ); + analysis = t("calibrated measurement"); + break; case "analysis_run_topic_lineage": - return ( - "No posts were available at this cutoff for topic-lineage analysis. " + - "Open a later run or retry after a newer snapshot is available." - ); + analysis = t("time-based topic analysis"); + break; case "analysis_run_lineage": - return ( - "No posts were available at this cutoff for reconstruction. " + - "Open a later run or retry after a newer snapshot is available." - ); + analysis = t("reconstruction"); + break; case "analysis_run_report": - return ( - "No posts were available at this cutoff for the period report. " + - "Open a later run or retry after a newer snapshot is available." - ); + analysis = t("the period report"); + break; default: { const unexpected: never = run.run_kind_code; return unexpected; } } + return tf("No posts were available at this cutoff for {analysis}. Open a later run or retry after a newer snapshot is available.", { analysis }); } /** @@ -3003,28 +3003,19 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { function analysisRunCorpusHint(run: AnalysisRun): string | null { const isTopicLineage = run.run_kind_code === "analysis_run_topic_lineage"; if (run.run_kind_code !== "analysis_run_tepp" && !isTopicLineage) return null; - const service = isTopicLineage ? "topic-lineage" : "TEPP"; - const result = isTopicLineage ? "a topic-identity result" : "a calibrated result"; - const verb = isTopicLineage ? "thread" : "measure"; - const verbPast = isTopicLineage ? "threaded" : "measured"; + const analysis = t(isTopicLineage ? "time-based topic analysis" : "calibrated measurement"); switch (run.status_code) { case "analysis_status_failed": - return ( - `These posts are the cutoff corpus ${service} would ${verb}. Connect a TEPP ` + - `transport, then re-run, to replace Failed with ${result}.` - ); + return tf("These posts were selected for {analysis}. Review the failure details, then retry with the latest available records.", { analysis }); case "analysis_status_succeeded": - return `These posts are the cutoff corpus this ${service} run ${verbPast}.`; + return tf("These posts were included in this {analysis} result.", { analysis }); case "analysis_status_pending": case "analysis_status_running": - return `These posts are the cutoff corpus ${service} will ${verb} once this run finishes.`; + return tf("These posts will be included when {analysis} finishes.", { analysis }); case "analysis_status_cancelled": - return ( - `These posts are the cutoff corpus this ${service} run would have ${verbPast}. ` + - `The run was cancelled before ${result}.` - ); + return tf("These posts were selected for {analysis}. Start a new run if the result is still needed.", { analysis }); case null: - return `These posts are the cutoff corpus attached to this ${service} run.`; + return tf("These posts are selected for {analysis}.", { analysis }); default: { const unexpected: never = run.status_code; return unexpected; @@ -3149,10 +3140,10 @@ function analysisRunCanStart(run: AnalysisRun): boolean { function analysisRunStartLabel(run: AnalysisRun): string { if (run.run_kind_code === "analysis_run_tepp") { - return "Start TEPP measurement"; + return "Start calibrated measurement"; } if (run.run_kind_code === "analysis_run_topic_lineage") { - return "Start topic lineage"; + return "Start time-based topic analysis"; } return "Start reconstruction"; } @@ -3444,9 +3435,9 @@ function AnalysisRunsPanel({ > {starting ? selected.run_kind_code === "analysis_run_tepp" - ? "Submitting the TEPP request..." + ? "Starting calibrated measurement..." : selected.run_kind_code === "analysis_run_topic_lineage" - ? "Submitting the topic-lineage request..." + ? "Starting time-based topic analysis..." : "Reconstructing the cutoff bag..." : analysisRunStartLabel(selected)} @@ -3454,10 +3445,8 @@ function AnalysisRunsPanel({ {analysisRunCanRequestTeppRetry(selected) && (

{selected.run_kind_code === "analysis_run_topic_lineage" - ? "Connect a TEPP transport from this Failed row. Request a " + - "lineage reconstruction does not invent a topic model." - : "Connect a TEPP transport from this Failed row. Request a lineage " + - "reconstruction does not invent a measurement."} + ? "Review the failure details, confirm the selected posts and period, then start a new topic analysis." + : "Review the failure details, confirm the selected posts and cutoff, then start a new calibrated measurement."}

)} {analysisRunReportPeriod(selected) && onSelectReportPeriod && ( diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index f7fb4d1c0..0681e6348 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard"; import "../App.css"; @@ -25,7 +25,7 @@ export const EvidenceReady: Story = { ], topic_context: { status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted", - next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", model_run: null, topics: [], + next_action: "Complete the time-based analysis, then review the influential posts.", model_run: null, topics: [], required_contracts: [ { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "not_persisted" }, { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "not_persisted" }, @@ -43,10 +43,10 @@ export const EvidenceReady: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument(); - await expect(canvas.getByText("7 Event · 5글")).toBeVisible(); - await expect(canvas.getByText("3일 3시간 30분 0초")).toBeVisible(); - await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible(); + await expect(canvas.getByText("9 posts · 22.5%")).toBeInTheDocument(); + await expect(canvas.getByText("7 events · 5 posts")).toBeVisible(); + await expect(canvas.getByText("3d 3h 30m 0s")).toBeVisible(); + await expect(canvas.getAllByRole("button", { name: "Open classification evidence" })[0]).toBeVisible(); }, }; @@ -96,10 +96,42 @@ export const TopicInfluenceAccepted: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByRole("heading", { name: "시간 흐름별 주요 글" })).toBeVisible(); - await expect(canvas.getByText(/휴면 \/ 재활성/)).toBeVisible(); + await expect(canvas.getByRole("heading", { name: "Important posts over time" })).toBeVisible(); + await expect(canvas.getByText(/Dormant \/ Reactivated/)).toBeVisible(); await expect(canvas.getAllByText("4.25")).toHaveLength(3); - await expect(canvas.getByText(/영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요/)).toBeVisible(); + await expect(canvas.getByText(/Compare influence and uncertainty together; identical values are ties/)).toBeVisible(); + }, +}; + +export const TopicInfluenceDark: Story = { + ...TopicInfluenceAccepted, + parameters: { chromatic: { prefersColorScheme: "dark" } }, +}; + +export const TopicInfluenceReducedMotion: Story = { + ...TopicInfluenceAccepted, + parameters: { chromatic: { prefersReducedMotion: "reduce" } }, +}; + +export const TopicInfluenceKeyboard: Story = { + ...TopicInfluenceAccepted, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.tab(); + await expect(canvas.getByRole("region", { name: "Synthetic Energy Division influence table" })).toBeVisible(); + const evidenceButton = canvas.getAllByRole("button", { name: "Open membership evidence" })[0]; + evidenceButton.focus(); + await expect(evidenceButton).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + }, +}; + +export const TopicInfluenceTouch: Story = { + ...TopicInfluenceAccepted, + parameters: { viewport: { defaultViewport: "mobile1" } }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getAllByRole("button", { name: "Open influential post" })[0]; + await expect(button).toBeVisible(); }, }; @@ -113,11 +145,11 @@ export const ExternalInformationEmpty: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByRole("status")).toHaveTextContent("분류된 외부 정보가 없습니다"); - await expect(canvas.queryByText("전체 글")).not.toBeInTheDocument(); - await expect(canvas.queryByText("분류 Event")).not.toBeInTheDocument(); - await expect(canvas.queryByText("분석 대기")).not.toBeInTheDocument(); - await expect(canvas.queryByText("분석 실패")).not.toBeInTheDocument(); + await expect(canvas.getByRole("status")).toHaveTextContent("No external information was classified"); + await expect(canvas.queryByText("All posts")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Cited case events")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Awaiting analysis")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Analysis failed")).not.toBeInTheDocument(); }, }; @@ -127,7 +159,7 @@ export const RequiredFactMissing: Story = { onOpenPost: () => undefined, }, play: async ({ canvasElement }) => { - await expect(within(canvasElement).getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeVisible(); + await expect(within(canvasElement).getByText(/Sales pool: Find and connect the related evidence, then review the refreshed result/)).toBeVisible(); }, }; @@ -137,7 +169,7 @@ export const AnalysisPendingAndMissingEvidence: Story = { onOpenPost: () => undefined, }, play: async ({ canvasElement }) => { - await expect(within(canvasElement).getByRole("status")).toHaveTextContent("분석 대기 건부터 처리하세요"); + await expect(within(canvasElement).getByRole("status")).toHaveTextContent("Process the awaiting items first"); }, }; @@ -147,7 +179,7 @@ export const AnalysisFailed: Story = { onOpenPost: () => undefined, }, play: async ({ canvasElement }) => { - await expect(within(canvasElement).getByRole("alert")).toHaveTextContent("재처리한 뒤 근거 누락 여부를 다시 확인하세요"); + await expect(within(canvasElement).getByRole("alert")).toHaveTextContent("Reprocess 2 failed analyses"); }, }; @@ -161,8 +193,8 @@ export const LoadError: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.findByRole("alert")).resolves.toHaveTextContent("불러오지 못했습니다"); - await expect(canvas.getByRole("button", { name: "다시 시도" })).toBeVisible(); + await expect(canvas.findByRole("alert")).resolves.toHaveTextContent("could not be loaded"); + await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible(); }, }; diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 4171de7cd..3b8b4fbab 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse } from "../api"; +import { setLocale } from "../i18n"; import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard"; vi.mock("../api", async (importOriginal) => ({ @@ -11,6 +12,7 @@ vi.mock("../api", async (importOriginal) => ({ })); beforeEach(() => { + setLocale("ko"); vi.mocked(fetchVoiceTaxonomySummary).mockReset().mockResolvedValue({ total_eligible: 0, classified_unique: 0, multi_membership: 0, source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0, @@ -61,10 +63,10 @@ describe("OperationsDashboardView", () => { it("distinguishes posts, events, percentages and opens evidence", async () => { const onOpenPost = vi.fn(); render(); - expect(screen.getByText("3 Event · 2글")).toBeInTheDocument(); + expect(screen.getByText("사건 Event 3건 · 글 2건")).toBeInTheDocument(); expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); expect(screen.getByText("원인 수주")).toBeInTheDocument(); - expect(screen.getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeInTheDocument(); + expect(screen.getByText(/수주 Pool: 관련 근거를 찾아 연결한 뒤 갱신된 결과를 확인하세요/)).toBeInTheDocument(); expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument(); expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" })); @@ -75,6 +77,54 @@ describe("OperationsDashboardView", () => { expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); }); + it.each([ + ["en", "Operations evidence dashboard", "All posts", "Important posts over time"], + ["zh", "运营证据看板", "全部文章", "时序重要文章"], + ["ja", "運用エビデンスダッシュボード", "すべての投稿", "時系列の重要投稿"], + ["vi", "Bảng điều khiển bằng chứng vận hành", "Tất cả bài viết", "Bài viết quan trọng theo thời gian"], + ] as const)("renders localized dashboard actions in %s", (locale, heading, allPosts, importantPosts) => { + setLocale(locale); + render( undefined} />); + expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument(); + expect(screen.getByText(allPosts)).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: importantPosts })).toBeInTheDocument(); + expect(screen.queryByText("운영 근거 대시보드")).not.toBeInTheDocument(); + if (locale !== "en") expect(screen.queryByText("Operations evidence dashboard")).not.toBeInTheDocument(); + expect(screen.queryByText(/TEPP|fast-mlsirm|transport|topic-lineage/i)).not.toBeInTheDocument(); + }); + + it("keeps the server next action for an unknown lifecycle status", () => { + setLocale("en"); + const future = { + ...data.cases[0].lifecycles[0], + status_code: "future_status" as never, + status_label: "Future status", + next_action_text: "Open the cited evidence, then choose the next owner.", + }; + render( undefined} />); + + expect(screen.getByText(/Open the cited evidence, then choose the next owner/)).toBeInTheDocument(); + }); + + it.each([ + ["en", "Open the available evidence, then confirm the next action."], + ["ko", "확인 가능한 근거를 연 뒤 다음 조치를 확인하세요."], + ["zh", "打开可用证据,然后确认下一步行动。"], + ["ja", "確認できる根拠を開き、次の行動を確認してください。"], + ["vi", "Mở bằng chứng hiện có rồi xác nhận hành động tiếp theo."], + ] as const)("keeps an actionable fallback for an unknown lifecycle status in %s", (locale, nextAction) => { + setLocale(locale); + const future = { + ...data.cases[0].lifecycles[0], + status_code: "future_status" as never, + status_label: "Future status", + next_action_text: undefined as unknown as string, + }; + render( undefined} />); + + expect(screen.getByText(new RegExp(nextAction.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument(); + }); + it("does not imply that only the end evidence is missing", () => { const openLifecycle = { ...data.cases[0].lifecycles[0], @@ -96,7 +146,7 @@ describe("OperationsDashboardView", () => { expect(screen.queryByText("분석 대기")).not.toBeInTheDocument(); expect(screen.queryByText("분석 실패")).not.toBeInTheDocument(); expect(screen.queryByText("전체 글")).not.toBeInTheDocument(); - expect(screen.queryByText("분류 Event")).not.toBeInTheDocument(); + expect(screen.queryByText("근거 확인된 사건 Event")).not.toBeInTheDocument(); }); it("does not label a scoped external count with a corpus-wide rate", () => { @@ -245,7 +295,7 @@ describe("OperationsDashboardView", () => { }); render( undefined} />); - expect(await screen.findByText("Dashboard 근거를 불러오지 못했습니다.")).toBeInTheDocument(); + expect(await screen.findByText("대시보드 근거를 불러오지 못했습니다.")).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "다시 시도" })).toBeInTheDocument(); }); @@ -257,7 +307,7 @@ describe("OperationsDashboard", () => { vi.mocked(fetchVoiceTaxonomySummary).mockImplementation(() => new Promise(() => undefined)); render( undefined} />); expect(screen.getAllByRole("status")).toHaveLength(1); - expect(screen.getByRole("status")).toHaveTextContent("Dashboard 근거를 불러오는 중입니다."); + expect(screen.getByRole("status")).toHaveTextContent("대시보드 근거를 불러오는 중입니다."); expect(screen.getByRole("status")).toHaveTextContent("Loading voice evidence..."); }); }); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 19cc49aaf..fe2b0d504 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -1,28 +1,94 @@ import { useEffect, useState } from "react"; import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse, type VoiceTaxonomySummary as VoiceSummary } from "../api"; -import { t } from "../i18n"; +import { t, tf, useLocale } from "../i18n"; import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary"; function formatElapsed(seconds: number): string { const days = Math.floor(seconds / 86_400); const hours = Math.floor((seconds % 86_400) / 3_600); const minutes = Math.floor((seconds % 3_600) / 60); - return `${days}일 ${hours}시간 ${minutes}분 ${seconds % 60}초`; + return tf("{days}d {hours}h {minutes}m {seconds}s", { days, hours, minutes, seconds: seconds % 60 }); } const dimensionLabels = { - business_unit: "사업부", + business_unit: "Business unit", process_unit: "PU", - team: "팀", - person: "개인", + team: "Team", + person: "Person", } as const; const topicStateLabels = { - active: "활성", - dormant: "휴면", - reactivated: "재활성", + active: "Active", + dormant: "Dormant", + reactivated: "Reactivated", } as const; +const topicEventLabels = { birth: "Started", split: "Split", merge: "Merged", retirement: "Ended" } as const; + +const caseKindLabels: Record = { + claim_investigation: "Claim investigation", + rebid_handover: "Rebid and handover", + external_information: "External information", + repeat_issue: "Recurring issue", +}; + +const factTypeLabels: Record = { + order: "Affected order", + specification_change: "Specification change", + originating_order: "Originating order", + sales_pool: "Sales pool", + discussion: "Discussion", + counterparty: "Counterparty", + our_owner: "Our owner", + decision: "Decision", + external_relation: "Business relationship", + issue_pattern: "Recurring pattern", + improvement_action: "Improvement action", +}; + +const lifecycleLabels: Record = { + claim_investigation: "Claim investigation", + rebid_response: "Rebid response", + handover_gap: "Handover gap", +}; + +const lifecycleStatusLabels: Record = { + open: "In progress", + resolved: "Completed", + evidence_missing: "Timing evidence needed", +}; + +const milestoneLabels: Record = { + claim_received: "Claim received", + cause_confirmed: "Cause confirmed", + rebid_started: "Rebid started", + response_submitted: "Response submitted", + handover_started: "Handover started", + handover_completed: "Handover completed", +}; + +const timeAxisLabels: Record = { + event_occurred_at: "Event date", + created_at: "Record creation date", +}; + +const relationTargetLabels: Record = { + order: "Order", + project: "Project", + sales: "Sales", + business_management: "Business management", +}; + +const lifecycleNextActions: Record = { + open: "Open the start evidence and track the next observed event.", + resolved: "Open the start and end evidence, then review the elapsed time.", + evidence_missing: "Find and connect the required start and end evidence, then review the refreshed interval.", +}; + +function controlledLabel(code: string, fallback: string, labels: Record): string { + return t(labels[code] ?? fallback); +} + type Props = { accessToken: string; externalOnly?: boolean; @@ -31,6 +97,7 @@ type Props = { /** Shows quantified operational cases and opens their cited source posts. */ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenPost }: Props) { + useLocale(); const [data, setData] = useState(null); const [error, setError] = useState(false); const [periodStart, setPeriodStart] = useState(""); @@ -67,15 +134,15 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP event.preventDefault(); setSubmittedPeriod([periodStart, periodEnd]); }}> - - - + + + {error ? (
-

운영 근거 Dashboard

-

Dashboard 근거를 불러오지 못했습니다.

- +

{t("Operations evidence dashboard")}

+

{t("Dashboard evidence could not be loaded.")}

+
) : data ? ( @@ -90,7 +157,7 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP ) : null} {(!data && !error) || (!externalOnly && !voiceSummary && !voiceSummaryError) ? (
- {!data && !error ?

Dashboard 근거를 불러오는 중입니다.

: null} + {!data && !error ?

{t("Loading dashboard evidence...")}

: null} {!externalOnly && !voiceSummary && !voiceSummaryError ?

{t("Loading voice evidence...")}

: null}
) : null} @@ -99,6 +166,7 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP /** Renders a completed Dashboard response for runtime and Storybook scenes. */ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost }: { data: OperationsDashboardResponse; externalOnly?: boolean; onOpenPost: (postId: string) => void }) { + useLocale(); const cases = externalOnly ? data.cases.filter((item) => item.case_kind_code === "external_information") : data.cases; const observedProjectEvents = Object.entries( cases.reduce>((groups, item) => { @@ -110,24 +178,24 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost return (
-

{data.period_label}

{externalOnly ? "외부 정보" : "운영 근거 Dashboard"}

-

수치를 선택하면 근거 글에서 다음 조치를 확인할 수 있습니다.

+

{data.period_label}

{t(externalOnly ? "External information" : "Operations evidence dashboard")}

+

{t("Select a value, then open its source post to confirm the next action.")}

- {!externalOnly ?
전체 글
{data.total_post_count}
: null} - {!externalOnly ?
분류 Event
{data.total_event_count}
: null} -
외부 정보
{data.external_post_count}건{externalOnly ? "" : ` · ${data.external_percent.toFixed(1)}%`}
- {!externalOnly ?
분석 대기
{data.pending_analysis_count}
: null} - {!externalOnly ?
분석 실패
{data.failed_analysis_count}
: null} + {!externalOnly ?
{t("All posts")}
{data.total_post_count}
: null} + {!externalOnly ?
{t("Cited case events")}
{data.total_event_count}
: null} +
{t("External information")}
{tf(externalOnly ? "{count} posts" : "{count} posts · {percent}%", { count: data.external_post_count, percent: data.external_percent.toFixed(1) })}
+ {!externalOnly ?
{t("Awaiting analysis")}
{data.pending_analysis_count}
: null} + {!externalOnly ?
{t("Analysis failed")}
{data.failed_analysis_count}
: null}
{!externalOnly ? (
-

업무 유형별 현황

+

{t("Status by work type")}

{data.case_metrics.map((metric) => (
-
{metric.case_kind_label}
-
{metric.event_count} Event · {metric.post_count}글
+
{controlledLabel(metric.case_kind_code, metric.case_kind_label, caseKindLabels)}
+
{tf("{events} case events · {posts} posts", { events: metric.event_count, posts: metric.post_count })}
))}
@@ -135,13 +203,13 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost ) : null} {!externalOnly ? (
-

관측된 처리 구간

-

시작과 종료 Event가 확인된 항목의 경과 시간을 비교하세요.

+

{t("Observed processing intervals")}

+

{t("Compare elapsed time for items with observed start and end events.")}

{data.lifecycle_metrics.map((metric) => (
-
{metric.lifecycle_kind_label}
-
진행 중 {metric.open_case_count}건 · 종료 확인 {metric.resolved_case_count}건 · 측정 근거 부족 {metric.evidence_missing_case_count}건
+
{controlledLabel(metric.lifecycle_kind_code, metric.lifecycle_kind_label, lifecycleLabels)}
+
{tf("{open} in progress · {resolved} completed · {missing} need timing evidence", { open: metric.open_case_count, resolved: metric.resolved_case_count, missing: metric.evidence_missing_case_count })}
))}
@@ -152,7 +220,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost ) : null} {!externalOnly && observedProjectEvents.length ? (
-

프로젝트별 관측 Event

+

{t("Observed events by project")}

{observedProjectEvents.map(([project, events]) => (

{project}

@@ -161,7 +229,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
  • ))} @@ -173,44 +241,44 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
    {cases.map((item) => (
    -
    {item.case_kind_label}{item.project_name ?? "프로젝트 연결 분석 중"}
    +
    {controlledLabel(item.case_kind_code, item.case_kind_label, caseKindLabels)}{item.project_name ?? t("Finding a related project")}

    {item.summary_text}

    {item.evidence_text}
    {item.lifecycles.length ? ( -
    +
    {item.lifecycles.map((lifecycle) => (
    -

    {lifecycle.lifecycle_kind_label}

    {lifecycle.status_label}
    - {lifecycle.elapsed_seconds !== null ?

    확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}

    :

    경과 시간은 필요한 시작·종료 사건 근거가 모두 관측될 때 계산됩니다.

    } +

    {controlledLabel(lifecycle.lifecycle_kind_code, lifecycle.lifecycle_kind_label, lifecycleLabels)}

    {controlledLabel(lifecycle.status_code, lifecycle.status_label, lifecycleStatusLabels)}
    + {lifecycle.elapsed_seconds !== null ?

    {t("Confirmed elapsed time")} {formatElapsed(lifecycle.elapsed_seconds)}

    :

    {t("Elapsed time is calculated after both required start and end evidence are observed.")}

    }
      {[lifecycle.start_milestone, lifecycle.end_milestone].filter((milestone) => milestone !== null).map((milestone) => (
    1. - {milestone.milestone_type_label} · {milestone.time_axis_label} - + {controlledLabel(milestone.milestone_type_code, milestone.milestone_type_label, milestoneLabels)} · {controlledLabel(milestone.time_axis_code, milestone.time_axis_label, timeAxisLabels)} +
    2. ))}
    -

    다음 조치: {lifecycle.next_action_text}

    +

    {t("Next action")}: {t(lifecycleNextActions[lifecycle.status_code] ?? lifecycle.next_action_text ?? "Open the available evidence, then confirm the next action.")}

    ))}
    ) : null} -
    {item.facts.map((fact) =>
    {fact.fact_type_label}{fact.relation_target_kind_label ? ` · ${fact.relation_target_kind_label}` : ""}
    {fact.value_text}
    )}
    +
    {item.facts.map((fact) => { const label = controlledLabel(fact.fact_type_code, fact.fact_type_label, factTypeLabels); const targetLabel = fact.relation_target_kind_code ? controlledLabel(fact.relation_target_kind_code, fact.relation_target_kind_label ?? fact.relation_target_kind_code, relationTargetLabels) : null; return
    {label}{targetLabel ? ` · ${targetLabel}` : ""}
    {fact.value_text}
    ; })}
    {item.missing_facts.length ? ( -
    -

    추가 확인 필요

    -
      {item.missing_facts.map((fact) =>
    • {fact.fact_type_label}: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요.
    • )}
    +
    +

    {t("Additional evidence needed")}

    +
      {item.missing_facts.map((fact) =>
    • {tf("{label}: Find and connect the related evidence, then review the refreshed result.", { label: controlledLabel(fact.fact_type_code, fact.fact_type_label, factTypeLabels) })}
    • )}
    ) : null} - +
    ))}
    {cases.length === 0 && (externalOnly || data.failed_analysis_count === 0) ? ( -

    {externalOnly ? "선택 기간에 분류된 외부 정보가 없습니다. 기간이나 접근 범위를 확인하세요." : data.pending_analysis_count > 0 ? "선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요." : "선택 기간에 분석할 수 있는 근거가 없습니다. 기간이나 접근 범위를 확인하세요."}

    +

    {t(externalOnly ? "No external information was classified in this period. Check the period or your access scope." : data.pending_analysis_count > 0 ? "No evidence has completed analysis in this period. Process the awaiting items first." : "No evidence can be analyzed in this period. Check the period or your access scope.")}

    ) : null} - {!externalOnly && data.failed_analysis_count > 0 ?

    분석 실패 {data.failed_analysis_count}건을 재처리한 뒤 근거 누락 여부를 다시 확인하세요.

    : null} + {!externalOnly && data.failed_analysis_count > 0 ?

    {tf("Reprocess {count} failed analyses, then check again for missing evidence.", { count: data.failed_analysis_count })}

    : null}
    ); } @@ -221,46 +289,46 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa return (
    -

    글 영향도

    시간 흐름별 주요 글

    -

    글을 제외했을 때 주제 흐름과 조직별 결과가 얼마나 달라지는지 확인하세요.

    +

    {t("Post influence")}

    {t("Important posts over time")}

    +

    {t("Compare how topic trends and organization-level results change when a post is excluded.")}

    {topicContext.status_code === "unavailable" ? (
    - 글 영향도를 아직 확인할 수 없습니다. -

    분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.

    + {t("Post influence is not available yet.")} +

    {t("Confirm event dates and organization memberships for the selected posts, then run the analysis again.")}

    ) : ( <> -

    각 글의 영향도와 불확실성을 비교하고 원문 근거를 확인하세요.

    +

    {t("Compare each post's influence and uncertainty, then open its source evidence.")}

    {topicContext.topics.map((topic) => (
    - 주제 {topic.topic_index + 1} · {topic.activity_intervals.map((interval) => topicStateLabels[interval.state_code]).join(" / ")} -
      + {tf("Topic {number}", { number: topic.topic_index + 1 })} · {topic.activity_intervals.map((interval) => t(topicStateLabels[interval.state_code])).join(" / ")} +
        {topic.activity_intervals.map((interval) => (
      • - {topicStateLabels[interval.state_code]} + {t(topicStateLabels[interval.state_code])}
      • ))}
      - {topic.lineage_events.length ?
        - {topic.lineage_events.map((event) =>
      • · {({ birth: "시작", split: "분기", merge: "통합", retirement: "종료" } as const)[event.event_code]}{event.target_topic_index === null ? "" : ` → 주제 ${event.target_topic_index + 1}`}
      • )} + {topic.lineage_events.length ?
          + {topic.lineage_events.map((event) =>
        • · {t(topicEventLabels[event.event_code])}{event.target_topic_index === null ? "" : ` → ${tf("Topic {number}", { number: event.target_topic_index + 1 })}`}
        • )}
        : null} {topic.contexts.map((context) => (
        -

        {dimensionLabels[context.dimension_code]} · {context.context_label}

        -
        +

        {t(dimensionLabels[context.dimension_code])} · {context.context_label}

        +
        - - + + {context.influences.map((influence) => ( - + - + ))}
        영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요.
        사건 발생일상태영향도불확실성소속 반영값근거
        {t("Compare influence and uncertainty together; identical values are ties.")}
        {t("Event date")}{t("Status")}{t("Influence")}{t("Uncertainty")}{t("Membership value")}{t("Evidence")}
        {topicStateLabels[influence.topic_state_code]}{t(topicStateLabels[influence.topic_state_code])} {influence.model_influence} {influence.uncertainty_lower_value}–{influence.uncertainty_upper_value} {influence.membership_weight}
        @@ -272,10 +340,10 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
        {topicContext.model_run ? (
        - 분석 기준 확인 + {t("Review analysis basis")}
        -
        반영 기준 시각
        -
        주제 수
        {topicContext.model_run.topic_count}
        +
        {t("Knowledge cutoff")}
        +
        {t("Topic count")}
        {topicContext.model_run.topic_count}
        ) : null} diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx index c58d54efb..afb921cba 100644 --- a/frontend/src/components/ProjectHistoryTimeline.test.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -87,7 +87,16 @@ const projection: ProjectHistoryProjection = { target_event_id: "voc", event_ids: ["award", "spec", "voc"], edges: [ - { parent_event_id: "award", child_event_id: "spec", fused_score: 0.91 }, + { + parent_event_id: "award", + child_event_id: "spec", + fused_score: 0.91, + temporal_evidence: { + truth_status_code: "inferred", + interval_relations: ["before"], + artifact_digest_sha256: "a".repeat(64), + }, + }, { parent_event_id: "spec", child_event_id: "voc", fused_score: 0.73 }, ], minimum_fused_score: 0.73, @@ -114,6 +123,7 @@ describe("ProjectHistoryTimeline", () => { expect(screen.queryByText("document_time")).not.toBeInTheDocument(); expect(screen.getByText("delivery")).toBeInTheDocument(); expect(screen.getByText("delivered")).toBeInTheDocument(); + expect(screen.getByText(/Time order checked/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /open source record: VOC received/i })); expect(onOpenPost).toHaveBeenCalledWith("post-voc"); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx index e0c2a1f2e..a9b994d09 100644 --- a/frontend/src/components/ProjectHistoryTimeline.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -259,6 +259,11 @@ export function ProjectHistoryTimeline({ {projectHistoryText(locale, "inferred")} + {path.edges.some((edge) => edge.temporal_evidence != null) ? ( + + {projectHistoryText(locale, "timeOrderChecked")} + + ) : null} ))}
      diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index f6e77aae6..1206f604d 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -18,6 +18,18 @@ afterEach(() => { }); describe("i18n", () => { + it.each([ + ["ko", "이 기준 시점에는 시간 흐름별 주제 분석에 사용할 글이 없습니다. 이후 실행을 열거나 새 스냅샷이 준비된 뒤 다시 시도하세요.", "시간 흐름별 주제 분석이 완료되면 이 글들이 포함됩니다."], + ["zh", "此截止时间没有可用于时序主题分析的文章。请打开较晚的运行,或在新快照可用后重试。", "时序主题分析完成后将包含这些文章。"], + ["ja", "この基準時点では時系列トピック分析に使用できる投稿がありません。後の実行を開くか、新しいスナップショットが利用可能になってから再試行してください。", "時系列トピック分析が完了すると、これらの投稿が含まれます。"], + ["vi", "Không có bài viết nào tại mốc này cho phân tích chủ đề theo thời gian. Hãy mở lần chạy muộn hơn hoặc thử lại khi có ảnh chụp mới.", "Các bài viết này sẽ được đưa vào khi phân tích chủ đề theo thời gian hoàn tất."], + ] as const)("localizes analysis-run empty and corpus next actions in %s", (locale, empty, corpus) => { + setLocale(locale); + const analysis = t("time-based topic analysis"); + expect(tf("No posts were available at this cutoff for {analysis}. Open a later run or retry after a newer snapshot is available.", { analysis })).toBe(empty); + expect(tf("These posts will be included when {analysis} finishes.", { analysis })).toBe(corpus); + }); + const requiredSharedLabels = [ "Language", "Evidence", @@ -97,6 +109,40 @@ describe("i18n", () => { expect(Object.keys(LOCALE_LABELS)).toHaveLength(5); }); + it.each(["ko", "zh", "ja", "vi"] as const)( + "translates customer-facing analysis run kinds in %s", + (locale) => { + setLocale(locale); + for (const key of [ + "Lineage reconstruction", + "Calibrated event measurement", + "Time-based topic analysis", + "Period report", + ]) { + expect(t(key), `${locale}:${key}`).not.toBe(key); + } + }, + ); + + it.each(["ko", "zh", "ja", "vi"] as const)( + "translates every analysis-run next action in %s", + (locale) => { + setLocale(locale); + for (const key of [ + "Open this run, then start reconstruction. Reconstruction has not started yet.", + "Open this run to confirm the posts included in measurement, then start it.", + "Open this run to confirm the posts and time period included in topic analysis, then start it.", + "Open this run to confirm which posts the period report will use. The report has not been built yet.", + "Open this run to see why it failed, then retry with the latest available records.", + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + "Open this run to see why it failed, then rebuild the period report from a current snapshot.", + "Refresh this run. Start already queued the work on the durable outbox.", + ]) { + expect(t(key), `${locale}:${key}`).not.toBe(key); + } + }, + ); + it.each([ ["en", "Workspace navigation"], ["ko", "워크스페이스 메뉴"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 0602b8e15..ea335dbd1 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -17,8 +17,227 @@ export function isSupportedLocale(value: unknown): value is Locale { const STORAGE_KEY = "lineageweave.locale"; +const OPERATIONS_TRANSLATIONS: Record<"zh" | "ja" | "vi", Record> = { + zh: { + "Start date": "开始日期", "End date": "结束日期", "Apply period": "应用期间", + "Operations evidence dashboard": "运营证据看板", "Dashboard evidence could not be loaded.": "无法加载看板证据。", "Loading dashboard evidence...": "正在加载看板证据…", + "Select a value, then open its source post to confirm the next action.": "选择一个数值,然后打开来源文章确认下一步行动。", "All posts": "全部文章", "Cited case events": "有证据的案例事件", + "{count} posts": "{count}篇", "{count} posts · {percent}%": "{count}篇 · {percent}%", "Awaiting analysis": "等待分析", "Analysis failed": "分析失败", + "Status by work type": "按工作类型查看状态", "{events} case events · {posts} posts": "{events}个案例事件 · {posts}篇文章", "Observed processing intervals": "已观测处理区间", + "Compare elapsed time for items with observed start and end events.": "比较已观测到开始和结束事件的项目耗时。", "{open} in progress · {resolved} completed · {missing} need timing evidence": "进行中{open}项 · 已完成{resolved}项 · {missing}项需要时间证据", + "Observed events by project": "按项目查看已观测事件", "Finding a related project": "正在查找相关项目", "Confirmed elapsed time": "已确认耗时", + "Elapsed time is calculated after both required start and end evidence are observed.": "只有观测到所需的开始和结束证据后才计算耗时。", "{days}d {hours}h {minutes}m {seconds}s": "{days}天 {hours}小时 {minutes}分 {seconds}秒", + "Open {label} evidence": "打开{label}证据", "Items requiring additional evidence": "需要补充证据的项目", "Additional evidence needed": "需要补充证据", + "{label}: Find and connect the related evidence, then review the refreshed result.": "{label}:查找并关联相关证据,然后查看更新结果。", "Open classification evidence": "打开分类证据", + "No external information was classified in this period. Check the period or your access scope.": "此期间没有已分类的外部信息。请检查期间或访问范围。", "No evidence has completed analysis in this period. Process the awaiting items first.": "此期间没有完成分析的证据。请先处理等待项目。", "No evidence can be analyzed in this period. Check the period or your access scope.": "此期间没有可分析的证据。请检查期间或访问范围。", "Reprocess {count} failed analyses, then check again for missing evidence.": "重新处理{count}项失败分析,然后再次检查缺失证据。", + "Post influence": "文章影响力", "Important posts over time": "时序重要文章", "Compare how topic trends and organization-level results change when a post is excluded.": "比较排除一篇文章后主题趋势和组织层级结果的变化。", "Post influence is not available yet.": "暂时无法查看文章影响力。", "Confirm event dates and organization memberships for the selected posts, then run the analysis again.": "确认所选文章的事件日期和组织归属后重新分析。", "Compare each post's influence and uncertainty, then open its source evidence.": "比较每篇文章的影响力和不确定性,然后打开来源证据。", + "Topic {number}": "主题{number}", "Topic {number} status over time": "主题{number}的时序状态", "Topic {number} change history": "主题{number}的变更历史", Active: "活跃", Dormant: "休眠", Reactivated: "重新活跃", Started: "开始", Split: "分化", Merged: "合并", Ended: "结束", "Business unit": "事业部", "Open event evidence": "打开事件证据", "{label} influence table": "{label}影响力表", "Compare influence and uncertainty together; identical values are ties.": "同时比较影响力和不确定性;相同数值表示并列。", "Event date": "事件日期", Status: "状态", Influence: "影响力", Uncertainty: "不确定性", "Membership value": "归属值", "Open membership evidence": "打开归属证据", "Open influential post": "打开重要文章", "Review analysis basis": "查看分析依据", "Knowledge cutoff": "知识截止时间", "Topic count": "主题数", + "Claim investigation": "索赔原因调查", "Rebid and handover": "重新投标与交接", "Recurring issue": "重复问题", "Affected order": "受影响订单", "Specification change": "规格变更", "Originating order": "原因订单", "Sales pool": "订单池", Discussion: "协商内容", Counterparty: "协商方", "Our owner": "我方负责人", Decision: "后续决策", "Business relationship": "业务关系", "Recurring pattern": "重复模式", "Improvement action": "改进措施", "Rebid response": "重新投标应对", "Handover gap": "交接空缺", "In progress": "进行中", Completed: "已完成", "Timing evidence needed": "需要时间证据", "Claim received": "收到索赔", "Cause confirmed": "原因已确认", "Rebid started": "重新投标已开始", "Response submitted": "应对已提交", "Handover started": "交接已开始", "Handover completed": "交接已完成", "Record creation date": "记录创建日期", Order: "订单", Sales: "销售", "Business management": "业务管理", "Open the start evidence and track the next observed event.": "打开开始证据并跟踪下一个观测事件。", "Open the start and end evidence, then review the elapsed time.": "打开开始和结束证据,然后查看耗时。", "Find and connect the required start and end evidence, then review the refreshed interval.": "查找并关联所需的开始和结束证据,然后查看更新后的区间。", + "Report · alert · MCP": "报告 · 提醒 · MCP", "{count} evidence documents are linked to this report.": "此报告关联了{count}份证据文档。", "You can subscribe to evidence-change alerts.": "您可以订阅证据变更提醒。", "Connect evidence to enable change-alert subscriptions.": "关联证据以启用变更提醒订阅。", + }, + ja: { + "Start date": "開始日", "End date": "終了日", "Apply period": "期間を適用", + "Operations evidence dashboard": "運用エビデンスダッシュボード", "Dashboard evidence could not be loaded.": "ダッシュボードの根拠を読み込めませんでした。", "Loading dashboard evidence...": "ダッシュボードの根拠を読み込み中…", + "Select a value, then open its source post to confirm the next action.": "値を選び、元の投稿を開いて次の行動を確認してください。", "All posts": "すべての投稿", "Cited case events": "根拠付きケースイベント", "{count} posts": "{count}件", "{count} posts · {percent}%": "{count}件 · {percent}%", "Awaiting analysis": "分析待ち", "Analysis failed": "分析失敗", "Status by work type": "業務種別の状況", "{events} case events · {posts} posts": "ケースイベント{events}件 · 投稿{posts}件", "Observed processing intervals": "観測済み処理区間", "Compare elapsed time for items with observed start and end events.": "開始と終了イベントが観測された項目の経過時間を比較してください。", "{open} in progress · {resolved} completed · {missing} need timing evidence": "進行中{open}件 · 完了{resolved}件 · 時間根拠が必要{missing}件", "Observed events by project": "プロジェクト別の観測イベント", "Finding a related project": "関連プロジェクトを確認中", "Confirmed elapsed time": "確定経過時間", "Elapsed time is calculated after both required start and end evidence are observed.": "必要な開始・終了根拠が両方観測された後に経過時間を計算します。", "{days}d {hours}h {minutes}m {seconds}s": "{days}日 {hours}時間 {minutes}分 {seconds}秒", "Open {label} evidence": "{label}の根拠を開く", "Items requiring additional evidence": "追加根拠が必要な項目", "Additional evidence needed": "追加根拠が必要", "{label}: Find and connect the related evidence, then review the refreshed result.": "{label}:関連根拠を見つけて接続し、更新結果を確認してください。", "Open classification evidence": "分類根拠を開く", "No external information was classified in this period. Check the period or your access scope.": "この期間に分類された外部情報はありません。期間またはアクセス範囲を確認してください。", "No evidence has completed analysis in this period. Process the awaiting items first.": "この期間に分析完了した根拠はありません。分析待ちを先に処理してください。", "No evidence can be analyzed in this period. Check the period or your access scope.": "この期間に分析できる根拠はありません。期間またはアクセス範囲を確認してください。", "Reprocess {count} failed analyses, then check again for missing evidence.": "失敗した分析{count}件を再処理し、根拠の不足を再確認してください。", + "Post influence": "投稿の影響度", "Important posts over time": "時系列の重要投稿", "Compare how topic trends and organization-level results change when a post is excluded.": "投稿を除外したときのトピック推移と組織階層別結果の変化を比較してください。", "Post influence is not available yet.": "投稿の影響度はまだ確認できません。", "Confirm event dates and organization memberships for the selected posts, then run the analysis again.": "選択した投稿のイベント日と組織所属を確認してから再分析してください。", "Compare each post's influence and uncertainty, then open its source evidence.": "各投稿の影響度と不確実性を比較し、元の根拠を開いてください。", "Topic {number}": "トピック{number}", "Topic {number} status over time": "トピック{number}の時系列状態", "Topic {number} change history": "トピック{number}の変更履歴", Active: "活動中", Dormant: "休止", Reactivated: "再活性", Started: "開始", Split: "分岐", Merged: "統合", Ended: "終了", "Business unit": "事業部", "Open event evidence": "イベント根拠を開く", "{label} influence table": "{label}の影響度表", "Compare influence and uncertainty together; identical values are ties.": "影響度と不確実性を併せて比較し、同じ値は同順位として確認してください。", "Event date": "イベント日", Status: "状態", Influence: "影響度", Uncertainty: "不確実性", "Membership value": "所属値", "Open membership evidence": "所属根拠を開く", "Open influential post": "重要投稿を開く", "Review analysis basis": "分析根拠を確認", "Knowledge cutoff": "知識の基準時刻", "Topic count": "トピック数", + "Claim investigation": "クレーム原因調査", "Rebid and handover": "再入札と引継ぎ", "Recurring issue": "反復問題", "Affected order": "発生受注", "Specification change": "仕様変更", "Originating order": "原因受注", "Sales pool": "受注プール", Discussion: "協議内容", Counterparty: "協議相手", "Our owner": "当社担当者", Decision: "後続決定", "Business relationship": "業務関係", "Recurring pattern": "反復パターン", "Improvement action": "改善対応", "Rebid response": "再入札対応", "Handover gap": "引継ぎ空白", "In progress": "進行中", Completed: "完了", "Timing evidence needed": "時間根拠が必要", "Claim received": "クレーム受付", "Cause confirmed": "原因確定", "Rebid started": "再入札開始", "Response submitted": "対応提出", "Handover started": "引継ぎ開始", "Handover completed": "引継ぎ完了", "Record creation date": "記録作成日", Order: "受注", Sales: "営業", "Business management": "事業管理", "Open the start evidence and track the next observed event.": "開始根拠を開き、次の観測イベントを追跡してください。", "Open the start and end evidence, then review the elapsed time.": "開始・終了根拠を開き、経過時間を確認してください。", "Find and connect the required start and end evidence, then review the refreshed interval.": "必要な開始・終了根拠を見つけて接続し、更新区間を確認してください。", + "Report · alert · MCP": "レポート · 通知 · MCP", "{count} evidence documents are linked to this report.": "このレポートには{count}件の根拠文書が関連付けられています。", "You can subscribe to evidence-change alerts.": "根拠変更の通知を購読できます。", "Connect evidence to enable change-alert subscriptions.": "根拠を接続して変更通知の購読を有効にしてください。", + }, + vi: { + "Start date": "Ngày bắt đầu", "End date": "Ngày kết thúc", "Apply period": "Áp dụng khoảng thời gian", + "Operations evidence dashboard": "Bảng điều khiển bằng chứng vận hành", "Dashboard evidence could not be loaded.": "Không thể tải bằng chứng của bảng điều khiển.", "Loading dashboard evidence...": "Đang tải bằng chứng của bảng điều khiển…", "Select a value, then open its source post to confirm the next action.": "Chọn một giá trị rồi mở bài nguồn để xác nhận hành động tiếp theo.", "All posts": "Tất cả bài viết", "Cited case events": "Sự kiện có bằng chứng", "{count} posts": "{count} bài", "{count} posts · {percent}%": "{count} bài · {percent}%", "Awaiting analysis": "Đang chờ phân tích", "Analysis failed": "Phân tích thất bại", "Status by work type": "Trạng thái theo loại công việc", "{events} case events · {posts} posts": "{events} sự kiện · {posts} bài viết", "Observed processing intervals": "Khoảng xử lý đã quan sát", "Compare elapsed time for items with observed start and end events.": "So sánh thời gian đã qua của các mục có sự kiện bắt đầu và kết thúc được quan sát.", "{open} in progress · {resolved} completed · {missing} need timing evidence": "{open} đang xử lý · {resolved} hoàn tất · {missing} cần bằng chứng thời gian", "Observed events by project": "Sự kiện đã quan sát theo dự án", "Finding a related project": "Đang tìm dự án liên quan", "Confirmed elapsed time": "Thời gian đã xác nhận", "Elapsed time is calculated after both required start and end evidence are observed.": "Thời gian chỉ được tính sau khi quan sát đủ bằng chứng bắt đầu và kết thúc.", "{days}d {hours}h {minutes}m {seconds}s": "{days} ngày {hours} giờ {minutes} phút {seconds} giây", "Open {label} evidence": "Mở bằng chứng {label}", "Items requiring additional evidence": "Mục cần thêm bằng chứng", "Additional evidence needed": "Cần thêm bằng chứng", "{label}: Find and connect the related evidence, then review the refreshed result.": "{label}: Tìm và liên kết bằng chứng liên quan rồi xem kết quả đã cập nhật.", "Open classification evidence": "Mở bằng chứng phân loại", "No external information was classified in this period. Check the period or your access scope.": "Không có thông tin bên ngoài được phân loại trong khoảng này. Hãy kiểm tra thời gian hoặc phạm vi truy cập.", "No evidence has completed analysis in this period. Process the awaiting items first.": "Không có bằng chứng hoàn tất phân tích trong khoảng này. Hãy xử lý các mục đang chờ trước.", "No evidence can be analyzed in this period. Check the period or your access scope.": "Không có bằng chứng có thể phân tích trong khoảng này. Hãy kiểm tra thời gian hoặc phạm vi truy cập.", "Reprocess {count} failed analyses, then check again for missing evidence.": "Xử lý lại {count} phân tích thất bại rồi kiểm tra lại bằng chứng còn thiếu.", + "Post influence": "Mức ảnh hưởng của bài viết", "Important posts over time": "Bài viết quan trọng theo thời gian", "Compare how topic trends and organization-level results change when a post is excluded.": "So sánh thay đổi của xu hướng chủ đề và kết quả theo cấp tổ chức khi loại một bài viết.", "Post influence is not available yet.": "Chưa thể xem mức ảnh hưởng của bài viết.", "Confirm event dates and organization memberships for the selected posts, then run the analysis again.": "Xác nhận ngày sự kiện và đơn vị tổ chức của các bài đã chọn rồi chạy lại phân tích.", "Compare each post's influence and uncertainty, then open its source evidence.": "So sánh ảnh hưởng và độ bất định của từng bài rồi mở bằng chứng nguồn.", "Topic {number}": "Chủ đề {number}", "Topic {number} status over time": "Trạng thái theo thời gian của chủ đề {number}", "Topic {number} change history": "Lịch sử thay đổi của chủ đề {number}", Active: "Đang hoạt động", Dormant: "Tạm ngưng", Reactivated: "Hoạt động lại", Started: "Bắt đầu", Split: "Tách", Merged: "Hợp nhất", Ended: "Kết thúc", "Business unit": "Khối kinh doanh", "Open event evidence": "Mở bằng chứng sự kiện", "{label} influence table": "Bảng ảnh hưởng {label}", "Compare influence and uncertainty together; identical values are ties.": "So sánh đồng thời ảnh hưởng và độ bất định; giá trị giống nhau là đồng hạng.", "Event date": "Ngày sự kiện", Status: "Trạng thái", Influence: "Ảnh hưởng", Uncertainty: "Độ bất định", "Membership value": "Giá trị thành viên", "Open membership evidence": "Mở bằng chứng thành viên", "Open influential post": "Mở bài viết quan trọng", "Review analysis basis": "Xem cơ sở phân tích", "Knowledge cutoff": "Mốc dữ liệu", "Topic count": "Số chủ đề", + "Claim investigation": "Điều tra nguyên nhân khiếu nại", "Rebid and handover": "Đấu thầu lại và bàn giao", "Recurring issue": "Vấn đề lặp lại", "Affected order": "Đơn hàng bị ảnh hưởng", "Specification change": "Thay đổi thông số", "Originating order": "Đơn hàng nguyên nhân", "Sales pool": "Nhóm đơn hàng", Discussion: "Nội dung trao đổi", Counterparty: "Đối tác trao đổi", "Our owner": "Người phụ trách", Decision: "Quyết định tiếp theo", "Business relationship": "Quan hệ nghiệp vụ", "Recurring pattern": "Mẫu lặp lại", "Improvement action": "Hành động cải tiến", "Rebid response": "Ứng phó đấu thầu lại", "Handover gap": "Khoảng trống bàn giao", "In progress": "Đang xử lý", Completed: "Hoàn tất", "Timing evidence needed": "Cần bằng chứng thời gian", "Claim received": "Đã nhận khiếu nại", "Cause confirmed": "Đã xác nhận nguyên nhân", "Rebid started": "Đã bắt đầu đấu thầu lại", "Response submitted": "Đã gửi phản hồi", "Handover started": "Đã bắt đầu bàn giao", "Handover completed": "Đã hoàn tất bàn giao", "Record creation date": "Ngày tạo bản ghi", Order: "Đơn hàng", Sales: "Bán hàng", "Business management": "Quản lý kinh doanh", "Open the start evidence and track the next observed event.": "Mở bằng chứng bắt đầu và theo dõi sự kiện được quan sát tiếp theo.", "Open the start and end evidence, then review the elapsed time.": "Mở bằng chứng bắt đầu và kết thúc rồi xem thời gian đã qua.", "Find and connect the required start and end evidence, then review the refreshed interval.": "Tìm và liên kết bằng chứng bắt đầu, kết thúc cần thiết rồi xem khoảng thời gian đã cập nhật.", + "Report · alert · MCP": "Báo cáo · cảnh báo · MCP", "{count} evidence documents are linked to this report.": "Có {count} tài liệu bằng chứng được liên kết với báo cáo này.", "You can subscribe to evidence-change alerts.": "Bạn có thể đăng ký cảnh báo thay đổi bằng chứng.", "Connect evidence to enable change-alert subscriptions.": "Liên kết bằng chứng để bật đăng ký cảnh báo thay đổi.", + }, +}; + +const ANALYSIS_RUN_ACTION_TRANSLATIONS: Record< + "ko" | "zh" | "ja" | "vi", + Record +> = { + ko: { + "Open this run, then start reconstruction. Reconstruction has not started yet.": "이 실행을 열고 이벤트 이력 재구성을 시작하세요. 아직 재구성이 시작되지 않았습니다.", + "Open this run to confirm the posts included in measurement, then start it.": "이 실행을 열어 측정 대상 글을 확인한 뒤 측정을 시작하세요.", + "Open this run to confirm the posts and time period included in topic analysis, then start it.": "이 실행을 열어 주제 분석 대상 글과 기간을 확인한 뒤 분석을 시작하세요.", + "Open this run to confirm which posts the period report will use. The report has not been built yet.": "이 실행을 열어 기간 리포트에 사용할 글을 확인하세요. 아직 리포트가 생성되지 않았습니다.", + "Open this run to see why it failed, then retry with the latest available records.": "이 실행을 열어 실패 원인을 확인한 뒤 최신 기록으로 다시 시도하세요.", + "Open this run to see why it failed, then retry reconstruction from a current snapshot.": "이 실행을 열어 실패 원인을 확인한 뒤 현재 스냅샷으로 재구성을 다시 시도하세요.", + "Open this run to see why it failed, then rebuild the period report from a current snapshot.": "이 실행을 열어 실패 원인을 확인한 뒤 현재 스냅샷으로 기간 리포트를 다시 생성하세요.", + "Refresh this run. Start already queued the work on the durable outbox.": "이 실행을 새로 고치세요. 시작 요청이 이미 처리 대기열에 등록되었습니다.", + "Open the available evidence, then confirm the next action.": "확인 가능한 근거를 연 뒤 다음 조치를 확인하세요.", + }, + zh: { + "Open this run, then start reconstruction. Reconstruction has not started yet.": "打开此运行并开始事件历程重建。重建尚未开始。", + "Open this run to confirm the posts included in measurement, then start it.": "打开此运行,确认纳入测量的文章后开始测量。", + "Open this run to confirm the posts and time period included in topic analysis, then start it.": "打开此运行,确认主题分析包含的文章和期间后开始分析。", + "Open this run to confirm which posts the period report will use. The report has not been built yet.": "打开此运行,确认周期报告将使用的文章。报告尚未生成。", + "Open this run to see why it failed, then retry with the latest available records.": "打开此运行查看失败原因,然后使用最新记录重试。", + "Open this run to see why it failed, then retry reconstruction from a current snapshot.": "打开此运行查看失败原因,然后从当前快照重新重建。", + "Open this run to see why it failed, then rebuild the period report from a current snapshot.": "打开此运行查看失败原因,然后从当前快照重新生成周期报告。", + "Refresh this run. Start already queued the work on the durable outbox.": "刷新此运行。启动请求已进入处理队列。", + "Open the available evidence, then confirm the next action.": "打开可用证据,然后确认下一步行动。", + }, + ja: { + "Open this run, then start reconstruction. Reconstruction has not started yet.": "この実行を開き、イベント履歴の再構成を開始してください。再構成はまだ始まっていません。", + "Open this run to confirm the posts included in measurement, then start it.": "この実行を開いて測定対象の投稿を確認し、測定を開始してください。", + "Open this run to confirm the posts and time period included in topic analysis, then start it.": "この実行を開いてトピック分析の対象投稿と期間を確認し、分析を開始してください。", + "Open this run to confirm which posts the period report will use. The report has not been built yet.": "この実行を開いて期間レポートに使用する投稿を確認してください。レポートはまだ作成されていません。", + "Open this run to see why it failed, then retry with the latest available records.": "この実行を開いて失敗理由を確認し、最新の記録で再試行してください。", + "Open this run to see why it failed, then retry reconstruction from a current snapshot.": "この実行を開いて失敗理由を確認し、現在のスナップショットから再構成を再試行してください。", + "Open this run to see why it failed, then rebuild the period report from a current snapshot.": "この実行を開いて失敗理由を確認し、現在のスナップショットから期間レポートを再作成してください。", + "Refresh this run. Start already queued the work on the durable outbox.": "この実行を更新してください。開始要求はすでに処理待ちに登録されています。", + "Open the available evidence, then confirm the next action.": "確認できる根拠を開き、次の行動を確認してください。", + }, + vi: { + "Open this run, then start reconstruction. Reconstruction has not started yet.": "Mở lần chạy này rồi bắt đầu tái dựng lịch sử sự kiện. Việc tái dựng chưa bắt đầu.", + "Open this run to confirm the posts included in measurement, then start it.": "Mở lần chạy này, xác nhận các bài viết được đo lường rồi bắt đầu.", + "Open this run to confirm the posts and time period included in topic analysis, then start it.": "Mở lần chạy này, xác nhận bài viết và khoảng thời gian phân tích chủ đề rồi bắt đầu.", + "Open this run to confirm which posts the period report will use. The report has not been built yet.": "Mở lần chạy này để xác nhận các bài viết dùng cho báo cáo theo kỳ. Báo cáo chưa được tạo.", + "Open this run to see why it failed, then retry with the latest available records.": "Mở lần chạy này để xem nguyên nhân thất bại rồi thử lại với bản ghi mới nhất.", + "Open this run to see why it failed, then retry reconstruction from a current snapshot.": "Mở lần chạy này để xem nguyên nhân thất bại rồi tái dựng lại từ ảnh chụp hiện tại.", + "Open this run to see why it failed, then rebuild the period report from a current snapshot.": "Mở lần chạy này để xem nguyên nhân thất bại rồi tạo lại báo cáo theo kỳ từ ảnh chụp hiện tại.", + "Refresh this run. Start already queued the work on the durable outbox.": "Làm mới lần chạy này. Yêu cầu bắt đầu đã được đưa vào hàng đợi xử lý.", + "Open the available evidence, then confirm the next action.": "Mở bằng chứng hiện có rồi xác nhận hành động tiếp theo.", + }, +}; + +const ANALYSIS_RUN_HINT_TRANSLATIONS: Record< + "ko" | "zh" | "ja" | "vi", + Record +> = { + ko: { + "calibrated measurement": "보정 측정", "time-based topic analysis": "시간 흐름별 주제 분석", reconstruction: "재구성", "the period report": "기간 리포트", + "No posts were available at this cutoff for {analysis}. Open a later run or retry after a newer snapshot is available.": "이 기준 시점에는 {analysis}에 사용할 글이 없습니다. 이후 실행을 열거나 새 스냅샷이 준비된 뒤 다시 시도하세요.", + "These posts were selected for {analysis}. Review the failure details, then retry with the latest available records.": "이 글들은 {analysis} 대상으로 선택되었습니다. 실패 내용을 확인한 뒤 최신 기록으로 다시 시도하세요.", + "These posts were included in this {analysis} result.": "이 글들은 이번 {analysis} 결과에 포함되었습니다.", "These posts will be included when {analysis} finishes.": "{analysis}이 완료되면 이 글들이 포함됩니다.", + "These posts were selected for {analysis}. Start a new run if the result is still needed.": "이 글들은 {analysis} 대상으로 선택되었습니다. 결과가 여전히 필요하면 새 실행을 시작하세요.", "These posts are selected for {analysis}.": "이 글들은 {analysis} 대상으로 선택되어 있습니다.", + }, + zh: { + "calibrated measurement": "校准测量", "time-based topic analysis": "时序主题分析", reconstruction: "重建", "the period report": "周期报告", + "No posts were available at this cutoff for {analysis}. Open a later run or retry after a newer snapshot is available.": "此截止时间没有可用于{analysis}的文章。请打开较晚的运行,或在新快照可用后重试。", + "These posts were selected for {analysis}. Review the failure details, then retry with the latest available records.": "这些文章已选用于{analysis}。请查看失败详情,然后使用最新记录重试。", + "These posts were included in this {analysis} result.": "这些文章已包含在本次{analysis}结果中。", "These posts will be included when {analysis} finishes.": "{analysis}完成后将包含这些文章。", + "These posts were selected for {analysis}. Start a new run if the result is still needed.": "这些文章已选用于{analysis}。如果仍需要结果,请开始新的运行。", "These posts are selected for {analysis}.": "这些文章已选用于{analysis}。", + }, + ja: { + "calibrated measurement": "校正測定", "time-based topic analysis": "時系列トピック分析", reconstruction: "再構成", "the period report": "期間レポート", + "No posts were available at this cutoff for {analysis}. Open a later run or retry after a newer snapshot is available.": "この基準時点では{analysis}に使用できる投稿がありません。後の実行を開くか、新しいスナップショットが利用可能になってから再試行してください。", + "These posts were selected for {analysis}. Review the failure details, then retry with the latest available records.": "これらの投稿は{analysis}の対象です。失敗内容を確認し、最新の記録で再試行してください。", + "These posts were included in this {analysis} result.": "これらの投稿は今回の{analysis}結果に含まれています。", "These posts will be included when {analysis} finishes.": "{analysis}が完了すると、これらの投稿が含まれます。", + "These posts were selected for {analysis}. Start a new run if the result is still needed.": "これらの投稿は{analysis}の対象です。結果が必要な場合は新しい実行を開始してください。", "These posts are selected for {analysis}.": "これらの投稿は{analysis}の対象として選択されています。", + }, + vi: { + "calibrated measurement": "đo lường hiệu chỉnh", "time-based topic analysis": "phân tích chủ đề theo thời gian", reconstruction: "tái dựng", "the period report": "báo cáo theo kỳ", + "No posts were available at this cutoff for {analysis}. Open a later run or retry after a newer snapshot is available.": "Không có bài viết nào tại mốc này cho {analysis}. Hãy mở lần chạy muộn hơn hoặc thử lại khi có ảnh chụp mới.", + "These posts were selected for {analysis}. Review the failure details, then retry with the latest available records.": "Các bài viết này đã được chọn cho {analysis}. Hãy xem chi tiết lỗi rồi thử lại với bản ghi mới nhất.", + "These posts were included in this {analysis} result.": "Các bài viết này được đưa vào kết quả {analysis} này.", "These posts will be included when {analysis} finishes.": "Các bài viết này sẽ được đưa vào khi {analysis} hoàn tất.", + "These posts were selected for {analysis}. Start a new run if the result is still needed.": "Các bài viết này đã được chọn cho {analysis}. Hãy bắt đầu lần chạy mới nếu vẫn cần kết quả.", "These posts are selected for {analysis}.": "Các bài viết này được chọn cho {analysis}.", + }, +}; + const TRANSLATIONS: Partial>> = { ko: { + ...ANALYSIS_RUN_ACTION_TRANSLATIONS.ko, + ...ANALYSIS_RUN_HINT_TRANSLATIONS.ko, + "Lineage reconstruction": "이벤트 이력 재구성", + "Calibrated event measurement": "보정된 이벤트 측정", + "Time-based topic analysis": "시간 흐름별 주제 분석", + "Period report": "기간 리포트", + "Start date": "시작일", + "End date": "종료일", + "Apply period": "기간 적용", + "Operations evidence dashboard": "운영 근거 대시보드", + "Dashboard evidence could not be loaded.": "대시보드 근거를 불러오지 못했습니다.", + "Loading dashboard evidence...": "대시보드 근거를 불러오는 중입니다.", + "Select a value, then open its source post to confirm the next action.": "수치를 선택한 뒤 원본 글을 열어 다음 조치를 확인하세요.", + "All posts": "전체 글", + "Cited case events": "근거 확인된 사건 Event", + "{count} posts": "{count}건", + "{count} posts · {percent}%": "{count}건 · {percent}%", + "Awaiting analysis": "분석 대기", + "Analysis failed": "분석 실패", + "Status by work type": "업무 유형별 현황", + "{events} case events · {posts} posts": "사건 Event {events}건 · 글 {posts}건", + "Observed processing intervals": "관측된 처리 구간", + "Compare elapsed time for items with observed start and end events.": "시작과 종료 Event가 확인된 항목의 경과 시간을 비교하세요.", + "{open} in progress · {resolved} completed · {missing} need timing evidence": "진행 중 {open}건 · 종료 확인 {resolved}건 · 측정 근거 부족 {missing}건", + "Observed events by project": "프로젝트별 관측 Event", + "Finding a related project": "관련 프로젝트 확인 중", + "Confirmed elapsed time": "확정 경과 시간", + "Elapsed time is calculated after both required start and end evidence are observed.": "경과 시간은 필요한 시작·종료 사건 근거가 모두 관측될 때 계산됩니다.", + "{days}d {hours}h {minutes}m {seconds}s": "{days}일 {hours}시간 {minutes}분 {seconds}초", + "Open {label} evidence": "{label} 근거 열기", + "Items requiring additional evidence": "추가 확인이 필요한 항목", + "Additional evidence needed": "추가 확인 필요", + "{label}: Find and connect the related evidence, then review the refreshed result.": "{label}: 관련 근거를 찾아 연결한 뒤 갱신된 결과를 확인하세요.", + "Open classification evidence": "분류 근거 글 열기", + "No external information was classified in this period. Check the period or your access scope.": "선택 기간에 분류된 외부 정보가 없습니다. 기간이나 접근 범위를 확인하세요.", + "No evidence has completed analysis in this period. Process the awaiting items first.": "선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요.", + "No evidence can be analyzed in this period. Check the period or your access scope.": "선택 기간에 분석할 수 있는 근거가 없습니다. 기간이나 접근 범위를 확인하세요.", + "Reprocess {count} failed analyses, then check again for missing evidence.": "분석 실패 {count}건을 재처리한 뒤 근거 누락 여부를 다시 확인하세요.", + "Post influence": "글 영향도", + "Important posts over time": "시간 흐름별 주요 글", + "Compare how topic trends and organization-level results change when a post is excluded.": "글을 제외했을 때 주제 흐름과 조직별 결과가 얼마나 달라지는지 확인하세요.", + "Post influence is not available yet.": "글 영향도를 아직 확인할 수 없습니다.", + "Confirm event dates and organization memberships for the selected posts, then run the analysis again.": "분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.", + "Compare each post's influence and uncertainty, then open its source evidence.": "각 글의 영향도와 불확실성을 비교한 뒤 원문 근거를 확인하세요.", + "Topic {number}": "주제 {number}", + "Topic {number} status over time": "주제 {number} 시간 상태", + "Topic {number} change history": "주제 {number} 변화 이력", + Active: "활성", + Dormant: "휴면", + Reactivated: "재활성", + Started: "시작", + Split: "분기", + Merged: "통합", + Ended: "종료", + "Business unit": "사업부", + "Open event evidence": "사건 근거 열기", + "{label} influence table": "{label} 영향도 표", + "Compare influence and uncertainty together; identical values are ties.": "영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요.", + "Event date": "사건 발생일", + Status: "상태", + Influence: "영향도", + Uncertainty: "불확실성", + "Membership value": "소속 반영값", + "Open membership evidence": "소속 근거 열기", + "Open influential post": "영향 글 열기", + "Review analysis basis": "분석 기준 확인", + "Knowledge cutoff": "반영 기준 시각", + "Topic count": "주제 수", + "Claim investigation": "클레임 원인 규명", + "Rebid and handover": "재입찰 · 인수인계", + "Recurring issue": "반복 이슈", + "Affected order": "발생 수주", + "Specification change": "사양 변경", + "Originating order": "원인 수주", + "Sales pool": "수주 Pool", + Discussion: "협의 내용", + Counterparty: "협의 상대", + "Our owner": "우리측 담당자", + Decision: "이어진 결정", + "Business relationship": "업무 관계", + "Recurring pattern": "반복 유형", + "Improvement action": "개선 과제", + "Rebid response": "재입찰 대응", + "Handover gap": "인수인계 공백", + "In progress": "진행 중", + Completed: "종료 확인", + "Timing evidence needed": "측정 근거 부족", + "Claim received": "클레임 접수", + "Cause confirmed": "원인 확정", + "Rebid started": "재입찰 시작", + "Response submitted": "대응 제출", + "Handover started": "인수인계 시작", + "Handover completed": "인수인계 완료", + "Record creation date": "기록 생성일", + Order: "수주", + Sales: "영업", + "Business management": "사업 관리", + "Open the start evidence and track the next observed event.": "시작 근거를 열고 다음 관측 Event를 추적하세요.", + "Open the start and end evidence, then review the elapsed time.": "시작·종료 근거를 연 뒤 경과 시간을 검토하세요.", + "Find and connect the required start and end evidence, then review the refreshed interval.": "필요한 시작·종료 근거를 찾아 연결한 뒤 갱신된 처리 구간을 검토하세요.", "Connect another perspective": "다른 관점 연결", "This post will be recorded as the evidence.": "이 글이 근거로 기록됩니다.", Perspective: "관점", @@ -606,6 +825,13 @@ const TRANSLATIONS: Partial>> = { "IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", }, zh: { + ...OPERATIONS_TRANSLATIONS.zh, + ...ANALYSIS_RUN_ACTION_TRANSLATIONS.zh, + ...ANALYSIS_RUN_HINT_TRANSLATIONS.zh, + "Lineage reconstruction": "事件历程重建", + "Calibrated event measurement": "校准事件测量", + "Time-based topic analysis": "时序主题分析", + "Period report": "周期报告", "Connect another perspective": "关联另一个观点", "This post will be recorded as the evidence.": "此文章将被记录为证据。", Perspective: "观点", @@ -1184,6 +1410,13 @@ const TRANSLATIONS: Partial>> = { "残余图秩 0 表示 IRT 主效应后没有残余结构。阅读观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。", }, ja: { + ...OPERATIONS_TRANSLATIONS.ja, + ...ANALYSIS_RUN_ACTION_TRANSLATIONS.ja, + ...ANALYSIS_RUN_HINT_TRANSLATIONS.ja, + "Lineage reconstruction": "イベント履歴の再構成", + "Calibrated event measurement": "校正済みイベント測定", + "Time-based topic analysis": "時系列トピック分析", + "Period report": "期間レポート", "Connect another perspective": "別の観点を関連付ける", "This post will be recorded as the evidence.": "この投稿が根拠として記録されます。", Perspective: "観点", @@ -1766,6 +1999,13 @@ const TRANSLATIONS: Partial>> = { "残差マップランク 0 は IRT 主効果後に残差構造がないことを示します。観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。", }, vi: { + ...OPERATIONS_TRANSLATIONS.vi, + ...ANALYSIS_RUN_ACTION_TRANSLATIONS.vi, + ...ANALYSIS_RUN_HINT_TRANSLATIONS.vi, + "Lineage reconstruction": "Tái dựng lịch sử sự kiện", + "Calibrated event measurement": "Đo lường sự kiện đã hiệu chỉnh", + "Time-based topic analysis": "Phân tích chủ đề theo thời gian", + "Period report": "Báo cáo theo kỳ", "Connect another perspective": "Liên kết góc nhìn khác", "This post will be recorded as the evidence.": "Bài đăng này sẽ được ghi nhận làm bằng chứng.", Perspective: "Góc nhìn", diff --git a/frontend/src/index.css b/frontend/src/index.css index d4c7db546..f005c781e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -68,6 +68,16 @@ } } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation: none !important; + transition: none !important; + } +} + /* Shell container – 1920px max width (§2.1.1) */ #root { width: 100%; diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index 4dceb6fbd..d0dacb3f3 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -28,6 +28,11 @@ export interface ProjectHistoryPathEdge { parent_event_id: string; child_event_id: string; fused_score: number; + temporal_evidence?: { + truth_status_code: ProjectHistoryTruthStatus; + interval_relations: string[]; + artifact_digest_sha256: string; + } | null; } export interface ProjectHistoryPriorPath { @@ -113,6 +118,7 @@ const MESSAGE_KEYS = [ "priorHistory", "noPriorHistory", "inferredBoundary", + "timeOrderChecked", "projectEvidence", "sourceRecordEvidence", "supportingRecordEvidence", @@ -164,6 +170,7 @@ const EN: Record = { priorHistory: "Related prior history", noPriorHistory: "No visible prior lineage path is recorded for this event.", inferredBoundary: "This is inferred related history, not causality or an authoritative assignment record.", + timeOrderChecked: "Time order checked. Open the records above to compare the supporting dates.", projectEvidence: "Project identity evidence", sourceRecordEvidence: "Source record", supportingRecordEvidence: "Supporting record", @@ -212,6 +219,7 @@ const MESSAGES: Record> = { priorHistory: "관련 과거 이력", noPriorHistory: "이 이벤트로 이어지는 공개 가능한 이전 계보가 없습니다.", inferredBoundary: "이는 추론된 관련 이력이며 인과관계나 권위 있는 인사 배정 기록이 아닙니다.", + timeOrderChecked: "시간 순서를 확인했습니다. 위 기록을 열어 근거 날짜를 비교하세요.", projectEvidence: "프로젝트 식별 근거", sourceRecordEvidence: "원천 기록", supportingRecordEvidence: "뒷받침 기록", @@ -257,6 +265,7 @@ const MESSAGES: Record> = { priorHistory: "相关既往历史", noPriorHistory: "此事件没有可见的既往谱系路径。", inferredBoundary: "这是推断的相关历史,并非因果关系或权威任命记录。", + timeOrderChecked: "时间顺序已核验。请打开上方记录比较依据日期。", projectEvidence: "项目身份依据", sourceRecordEvidence: "来源记录", supportingRecordEvidence: "支持记录", @@ -302,6 +311,7 @@ const MESSAGES: Record> = { priorHistory: "関連する過去履歴", noPriorHistory: "このイベントに至る可視の過去系譜はありません。", inferredBoundary: "これは推論された関連履歴であり、因果関係や権威ある配属記録ではありません。", + timeOrderChecked: "時間順序を確認しました。上の記録を開いて根拠の日付を比較してください。", projectEvidence: "プロジェクト識別根拠", sourceRecordEvidence: "元レコード", supportingRecordEvidence: "根拠レコード", @@ -347,6 +357,7 @@ const MESSAGES: Record> = { priorHistory: "Lịch sử trước đó có liên quan", noPriorHistory: "Không có đường dẫn lịch sử trước đó khả kiến cho sự kiện này.", inferredBoundary: "Đây là lịch sử liên quan được suy luận, không phải quan hệ nhân quả hay hồ sơ phân công có thẩm quyền.", + timeOrderChecked: "Thứ tự thời gian đã được kiểm tra. Hãy mở các bản ghi trên để so sánh ngày làm căn cứ.", projectEvidence: "Bằng chứng nhận dạng dự án", sourceRecordEvidence: "Bản ghi nguồn", supportingRecordEvidence: "Bản ghi hỗ trợ", diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 284819153..a8f50e8a0 100644 --- a/frontend/src/styles/tokens.test.ts +++ b/frontend/src/styles/tokens.test.ts @@ -7,11 +7,21 @@ import { describe, expect, it } from "vitest"; const here = dirname(fileURLToPath(import.meta.url)); const tokensCss = readFileSync(join(here, "tokens.css"), "utf-8"); const appCss = readFileSync(join(here, "..", "App.css"), "utf-8"); +const indexCss = readFileSync(join(here, "..", "index.css"), "utf-8"); const publicClaimCss = readFileSync( join(here, "..", "components", "PublicClaimVerification.css"), "utf-8", ); +describe("reduced motion", () => { + it("removes animation and transition motion when the user requests it", () => { + const reducedMotion = indexCss.split("@media (prefers-reduced-motion: reduce)")[1]; + expect(reducedMotion).toContain("animation: none !important"); + expect(reducedMotion).toContain("transition: none !important"); + expect(reducedMotion).toContain("scroll-behavior: auto !important"); + }); +}); + const [lightBlock, darkBlock] = tokensCss.split("@media (prefers-color-scheme: dark)"); const BADGE_AND_ACCENT_TOKENS = [ diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py index 8a920eebe..392bf00df 100644 --- a/lineageweave/http_client.py +++ b/lineageweave/http_client.py @@ -14,6 +14,7 @@ import http.client import json +import os import ssl from collections.abc import Callable from urllib.parse import urlencode, urlparse @@ -28,6 +29,7 @@ _SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) _ALLOWED_SCHEMES = frozenset({"http", "https"}) _SESSION_HEADER_PEERS = frozenset({"contextual-orchestrator", "tepp"}) +_ROUTABLE_ORCHESTRATOR_PATHS = frozenset({"/v1/chat/completions", "/v1/responses"}) class HttpClientError(RuntimeError): @@ -47,10 +49,10 @@ def __init__( self.retryable = retryable class HttpAdmissionDeferred(HttpClientError): - """The orchestrator admitted no provider work and supplied an exact retry delay.""" + """The orchestrator deferred provider work and supplied an exact retry delay.""" def __init__(self, retry_after_seconds: int) -> None: - super().__init__("remote service has no viable agent yet") + super().__init__("remote service deferred provider admission") self.retry_after_seconds = retry_after_seconds @@ -292,6 +294,7 @@ def post_json( headers: dict[str, str], timeout: float, service_peer_name: str = "contextual-orchestrator", + routing_endpoint: str | None = None, ) -> dict: """POST ``payload`` as JSON to ``url`` and return the decoded object. @@ -300,8 +303,34 @@ def post_json( HttpClientError: the server responded with HTTP >= 400 or non-JSON. ``service_peer_name`` is a bounded service name used for the request span. + ``routing_endpoint`` overrides the deployment selector for this call. """ - hostname = urlparse(url).hostname or url + parsed_url = urlparse(url) + hostname = parsed_url.hostname or url + if routing_endpoint is not None and not isinstance(routing_endpoint, str): + raise ValueError("routing_endpoint must be a string") + explicit_selector = routing_endpoint.strip() if routing_endpoint is not None else "" + selector = explicit_selector or os.environ.get( + "ORCHESTRATOR_ROUTING_ENDPOINT", "" + ).strip() + request_payload = payload + if ( + selector + and service_peer_name == "contextual-orchestrator" + and parsed_url.path in _ROUTABLE_ORCHESTRATOR_PATHS + ): + existing_routing = payload.get("routing") + if existing_routing is None: + request_payload = {**payload, "routing": {"endpoint": selector}} + elif not isinstance(existing_routing, dict): + raise ValueError("routing must be an object") + elif existing_routing.get("endpoint") not in (None, selector): + raise ValueError("routing.endpoint conflicts with the requested endpoint") + elif existing_routing.get("endpoint") is None: + request_payload = { + **payload, + "routing": {**existing_routing, "endpoint": selector}, + } request_headers = {"content-type": "application/json", **headers} session_id = current_session_id() if session_id: @@ -320,7 +349,7 @@ def post_json( "POST", url, body=json_request_body( - payload, + request_payload, include_orchestrator_session=( service_peer_name == "contextual-orchestrator" ), @@ -338,26 +367,28 @@ def post_json( error_payload = _decode_json_object(raw, hostname).get("error") except HttpClientError: error_payload = None - if status == 503: + admission_code = ( + error_payload.get("code") if isinstance(error_payload, dict) else None + ) + if (status, admission_code) in { + (429, "rate_limit_exceeded"), + (503, "no_viable_agent"), + }: + detail = error_payload.get("detail") + retry_after = response_control_headers.get("retry-after", "") + detail_seconds = ( + detail.get("retry_after_seconds") + if isinstance(detail, dict) + else None + ) if ( - isinstance(error_payload, dict) - and error_payload.get("code") == "no_viable_agent" + retry_after.isascii() + and retry_after.isdigit() + and int(retry_after) > 0 + and type(detail_seconds) is int + and detail_seconds == int(retry_after) ): - detail = error_payload.get("detail") - retry_after = response_control_headers.get("retry-after", "") - detail_seconds = ( - detail.get("retry_after_seconds") - if isinstance(detail, dict) - else None - ) - if ( - retry_after.isascii() - and retry_after.isdigit() - and int(retry_after) > 0 - and type(detail_seconds) is int - and detail_seconds == int(retry_after) - ): - raise HttpAdmissionDeferred(detail_seconds) + raise HttpAdmissionDeferred(detail_seconds) error_code = ( error_payload.get("code") if isinstance(error_payload, dict) diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py index 281586b03..04db6ed4f 100644 --- a/lineageweave/project_history.py +++ b/lineageweave/project_history.py @@ -148,6 +148,17 @@ def _prior_paths( "parent_event_id": parent, "child_event_id": child, "fused_score": _score(row["fused_score"]), + "temporal_evidence": ( + { + "truth_status_code": ( + "observed" if row.get("temporal_observed") else "inferred" + ), + "interval_relations": list(row.get("allen_relations") or ()), + "artifact_digest_sha256": row.get("artifact_digest_sha256"), + } + if row.get("artifact_digest_sha256") is not None + else None + ), } ) for edges in reverse_edges.values(): diff --git a/lineageweave/public_claim_envelope.py b/lineageweave/public_claim_envelope.py new file mode 100644 index 000000000..a33d960d4 --- /dev/null +++ b/lineageweave/public_claim_envelope.py @@ -0,0 +1,64 @@ +"""Persisted admission envelopes for public Global Ask verification. + +The envelope decides which already-cited public assertion may leave the +workspace boundary. Retrieval and adjudication remain owned by the existing +claim-verification clients; this module never derives a claim from question +tokens or source text. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .claim_verification import PublicClaimCandidate + +ADMITTED_PUBLIC_CLAIM_KINDS = frozenset( + { + "claim_organization_presence", + "claim_public_event", + "claim_public_relationship", + } +) + + +@dataclass(frozen=True) +class PersistedPublicClaimEnvelope: + """One governed claim and its exact authorized source-post provenance.""" + + public_claim_envelope_id: str + source_post_id: str + claim_kind_code: str + claim_text: str + + def verification_candidate(self) -> PublicClaimCandidate: + """Project the persisted envelope into the existing verifier contract.""" + + return PublicClaimCandidate( + claim_text=self.claim_text, + claim_kind=self.claim_kind_code, + source_post_ids=(self.source_post_id,), + ) + + +def envelope_from_authorized_row(row: Any) -> PersistedPublicClaimEnvelope | None: + """Validate a database row already filtered by ABAC and PROV-O binding.""" + + kind = str(row["claim_kind_code"] or "").strip() + envelope_id = str(row["public_claim_envelope_id"] or "").strip() + source_post_id = str(row["source_post_id"] or "").strip() + claim_text = str(row["claim_text"] or "").strip() + if ( + kind not in ADMITTED_PUBLIC_CLAIM_KINDS + or not envelope_id + or not source_post_id + or not claim_text + or len(claim_text) > 800 + ): + return None + return PersistedPublicClaimEnvelope( + public_claim_envelope_id=envelope_id, + source_post_id=source_post_id, + claim_kind_code=kind, + claim_text=claim_text, + ) diff --git a/lineageweave/temporal_journey_artifact.py b/lineageweave/temporal_journey_artifact.py new file mode 100644 index 000000000..ef54513a1 --- /dev/null +++ b/lineageweave/temporal_journey_artifact.py @@ -0,0 +1,119 @@ +"""Validate TEPP interval-consistency artifacts without inventing journeys.""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Final + +SCHEMA_VERSION: Final = "tepp.tdt_chronos_interval_consistency.v1" +MAX_ARTIFACT_BYTES: Final = 4 * 1024 * 1024 +MAX_RELATIONS: Final = 100_000 +ALLEN_RELATIONS: Final = ( + "before", "after", "meets", "met_by", "overlaps", "overlapped_by", + "starts", "started_by", "during", "contains", "finishes", "finished_by", "equals", +) +_DIGEST = re.compile(r"^[0-9a-f]{64}$") + + +class TemporalJourneyArtifactError(ValueError): + """A fail-closed temporal-artifact contract violation.""" + + +@dataclass(frozen=True) +class TemporalRelation: + """One bounded observed or closure-derived interval relation.""" + + left_event_id: str + right_event_id: str + allen_relations: tuple[str, ...] + observed: bool + support_assertion_ordinals: tuple[int, ...] + + +@dataclass(frozen=True) +class TemporalJourneyArtifact: + """A canonical digest-bound interval-consistency artifact.""" + + run_id: str + snapshot_id: str + input_digest_sha256: str + relations: tuple[TemporalRelation, ...] + artifact_digest_sha256: str + + +def parse_temporal_journey_artifact( + payload: bytes, + *, + expected_run_id: str, + expected_snapshot_id: str, + expected_input_digest_sha256: str, + expected_artifact_digest_sha256: str, +) -> TemporalJourneyArtifact: + """Parse canonical provider JSON and bind every caller-owned identity.""" + + if not payload or len(payload) > MAX_ARTIFACT_BYTES: + raise TemporalJourneyArtifactError("artifact size is outside the supported bound") + if not all( + _DIGEST.fullmatch(value) + for value in (expected_input_digest_sha256, expected_artifact_digest_sha256) + ): + raise TemporalJourneyArtifactError("expected digest is not lowercase SHA-256") + if hashlib.sha256(payload).hexdigest() != expected_artifact_digest_sha256: + raise TemporalJourneyArtifactError("artifact bytes do not match the expected digest") + try: + decoded = payload.decode("utf-8") + value = json.loads(decoded) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise TemporalJourneyArtifactError("artifact is not valid UTF-8 JSON") from exc + if json.dumps(value, ensure_ascii=False, separators=(",", ":")) != decoded: + raise TemporalJourneyArtifactError("artifact JSON is not canonical") + if not isinstance(value, dict) or set(value) != { + "schema_version", "run_id", "snapshot_id", "input_digest_sha256", "relations" + }: + raise TemporalJourneyArtifactError("artifact object shape is unsupported") + if ( + value["schema_version"] != SCHEMA_VERSION + or value["run_id"] != expected_run_id + or value["snapshot_id"] != expected_snapshot_id + or value["input_digest_sha256"] != expected_input_digest_sha256 + ): + raise TemporalJourneyArtifactError("artifact identity does not match the admitted run") + raw_relations = value["relations"] + if not isinstance(raw_relations, list) or not 1 <= len(raw_relations) <= MAX_RELATIONS: + raise TemporalJourneyArtifactError("relation count is outside the supported bound") + parsed: list[TemporalRelation] = [] + previous: tuple[str, str] | None = None + for item in raw_relations: + if not isinstance(item, dict) or set(item) != { + "left_event_id", "right_event_id", "allen_relations", "observed", + "support_assertion_ordinals", + }: + raise TemporalJourneyArtifactError("relation object shape is unsupported") + left, right = item["left_event_id"], item["right_event_id"] + relations, support = item["allen_relations"], item["support_assertion_ordinals"] + key = (left, right) if isinstance(left, str) and isinstance(right, str) else ("", "") + if ( + not key[0].strip() or not key[1].strip() or key[0] == key[1] + or previous is not None and previous >= key + or not isinstance(item["observed"], bool) + or not isinstance(relations, list) or not relations + or any(relation not in ALLEN_RELATIONS for relation in relations) + or relations != sorted(set(relations), key=ALLEN_RELATIONS.index) + or len(relations) == len(ALLEN_RELATIONS) + or not isinstance(support, list) or not support + or any(isinstance(ordinal, bool) or not isinstance(ordinal, int) or ordinal < 0 for ordinal in support) + or support != sorted(set(support)) + ): + raise TemporalJourneyArtifactError("relation value is invalid or noncanonical") + parsed.append(TemporalRelation(key[0], key[1], tuple(relations), item["observed"], tuple(support))) + previous = key + return TemporalJourneyArtifact( + expected_run_id, + expected_snapshot_id, + expected_input_digest_sha256, + tuple(parsed), + expected_artifact_digest_sha256, + ) diff --git a/lineageweave/topic_influence_client.py b/lineageweave/topic_influence_client.py new file mode 100644 index 000000000..cfb9d350c --- /dev/null +++ b/lineageweave/topic_influence_client.py @@ -0,0 +1,368 @@ +"""Strict transport contract for externally computed topic-context influence. + +LineageWeave only validates and moves evidence. TEPP owns temporal topic +posterior evidence and fast-mlsirm owns the Rust case-deletion computation +defined by ADR 0210. +""" + +from __future__ import annotations + +import hashlib +import base64 +import binascii +import json +import math +import re +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Callable + +from .http_client import post_json + +REQUEST_SCHEMA_VERSION = "lineageweave.topic_context_influence_request.v1" +RESULT_SCHEMA_VERSION = "fast_mlsirm.topic_context_influence.v1" +_SHA256 = re.compile(r"[0-9a-f]{64}") +_REVISION = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})") +_DIMENSIONS = frozenset({"business_unit", "process_unit", "team", "person"}) + + +class TopicInfluenceNotAvailable(RuntimeError): + """Raised when no fast-mlsirm topic-influence transport is configured.""" + + +class TopicInfluenceInvalidResponse(ValueError): + """Raised when a result is incomplete or not bound to its request.""" + + +def _json_artifact_bytes(value: object) -> bytes: + """Encode one LineageWeave-owned request artifact for exact transport.""" + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + +@dataclass(frozen=True) +class TopicInfluenceRequest: + """One immutable TEPP posterior and multiple-membership evidence request.""" + + payload: dict[str, Any] + artifact_bytes: bytes + + @property + def request_sha256(self) -> str: + """Return the content identity of the exact producer input.""" + return hashlib.sha256(self.artifact_bytes).hexdigest() + + @property + def membership_fingerprint_sha256(self) -> str: + """Return the declared source-derived membership design identity.""" + return str(self.payload["membership_fingerprint_sha256"]) + + def to_json(self) -> dict[str, Any]: + """Transport exact owned bytes and the opaque identity the producer echoes.""" + return { + "request_sha256": self.request_sha256, + "request_base64": base64.b64encode(self.artifact_bytes).decode("ascii"), + } + + +@dataclass(frozen=True) +class TopicInfluenceResult: + """Validated fast-mlsirm result ready for exact persistence.""" + + payload: dict[str, Any] + + +def build_topic_influence_request( + *, + tepp_run: dict[str, Any], + topics: list[int], + observations: list[dict[str, Any]], +) -> TopicInfluenceRequest: + """Build and validate one request without performing numerical work.""" + required_run = { + "tepp_run_id", + "tepp_artifact_sha256", + "source_snapshot_sha256", + "knowledge_cutoff", + "posterior_draw_set_id", + "posterior_draw_count", + "coordinate_kind_code", + "topic_model_run_id", + } + if set(tepp_run) != required_run or not _SHA256.fullmatch( + str(tepp_run.get("tepp_artifact_sha256", "")) + ) or not _SHA256.fullmatch(str(tepp_run.get("source_snapshot_sha256", ""))): + raise ValueError("TEPP run evidence is incomplete") + if ( + not isinstance(tepp_run["posterior_draw_count"], int) + or isinstance(tepp_run["posterior_draw_count"], bool) + or tepp_run["posterior_draw_count"] <= 0 + or tepp_run["coordinate_kind_code"] + not in {"logistic_normal_coordinate", "plausible_value"} + ): + raise ValueError("TEPP posterior contract is invalid") + if not topics or any(type(topic) is not int or topic < 0 for topic in topics): + raise ValueError("topic identities must be non-empty non-negative integers") + if len(set(topics)) != len(topics): + raise ValueError("topic identities must be unique") + + if not observations: + raise ValueError("topic observations must be non-empty") + membership_material: list[dict[str, Any]] = [] + observed_dimensions: set[str] = set() + seen_membership_ids: set[str] = set() + seen_posts: set[str] = set() + for observation in observations: + if set(observation) != {"post_id", "event_time", "coordinates", "memberships"}: + raise ValueError("topic observation shape is invalid") + post_id = observation["post_id"] + if not isinstance(post_id, str) or not post_id.strip() or post_id in seen_posts: + raise ValueError("topic observation post identity is invalid") + seen_posts.add(post_id) + coordinates = observation["coordinates"] + memberships = observation["memberships"] + if ( + not isinstance(coordinates, list) + or not coordinates + or not isinstance(memberships, list) + or not memberships + ): + raise ValueError("topic observation requires coordinates and memberships") + expected_coordinates = { + (topic, draw) + for topic in topics + for draw in range(tepp_run["posterior_draw_count"]) + } + actual_coordinates: set[tuple[int, int]] = set() + for coordinate in coordinates: + if set(coordinate) != {"topic_index", "posterior_draw_ordinal", "value"}: + raise ValueError("topic coordinate shape is invalid") + key = (coordinate["topic_index"], coordinate["posterior_draw_ordinal"]) + value = coordinate["value"] + if ( + key in actual_coordinates + or type(value) not in {int, float} + or not math.isfinite(value) + ): + raise ValueError("topic coordinate is duplicate or non-finite") + actual_coordinates.add(key) + if actual_coordinates != expected_coordinates: + raise ValueError("topic coordinates are incomplete") + for membership in memberships: + if set(membership) != { + "membership_id", + "dimension_code", + "context_id", + "weight", + "valid_from", + "valid_to", + "evidence_sha256", + "provenance_assertion_id", + }: + raise ValueError("topic membership shape is invalid") + membership_id = membership["membership_id"] + dimension = membership["dimension_code"] + context_id = membership["context_id"] + weight = membership["weight"] + if ( + not isinstance(membership_id, str) + or not membership_id.strip() + or membership_id in seen_membership_ids + or dimension not in _DIMENSIONS + or not isinstance(context_id, str) + or not context_id.strip() + or type(weight) not in {int, float} + or not math.isfinite(weight) + or weight <= 0 + or not _SHA256.fullmatch(str(membership["evidence_sha256"])) + ): + raise ValueError("topic membership evidence is invalid") + seen_membership_ids.add(membership_id) + observed_dimensions.add(dimension) + membership_material.append( + {"post_id": post_id, **membership} + ) + if observed_dimensions != _DIMENSIONS: + raise ValueError("topic run requires evidence across all four context dimensions") + membership_material.sort( + key=lambda row: ( + row["post_id"], + row["dimension_code"], + row["context_id"], + row["membership_id"], + ) + ) + membership_artifact_bytes = _json_artifact_bytes(membership_material) + payload = { + "schema_version": REQUEST_SCHEMA_VERSION, + "requested_result_schema_version": RESULT_SCHEMA_VERSION, + "tepp_run": tepp_run, + "topic_indices": sorted(topics), + "observations": observations, + "membership_artifact_base64": base64.b64encode( + membership_artifact_bytes + ).decode("ascii"), + "membership_fingerprint_sha256": hashlib.sha256( + membership_artifact_bytes + ).hexdigest(), + } + return TopicInfluenceRequest(payload, _json_artifact_bytes(payload)) + + +def validate_topic_influence_result( + request: TopicInfluenceRequest, response: object +) -> TopicInfluenceResult: + """Admit one exact, complete, converged, digest-bound producer result.""" + required = { + "schema_version", + "request_sha256", + "tepp_run_id", + "source_snapshot_sha256", + "knowledge_cutoff", + "membership_fingerprint_sha256", + "producer_version", + "code_revision", + "compute_backend_code", + "precision_code", + "posterior_draw_coverage", + "convergence_status_code", + "identification_status_code", + "parity_status_code", + "influences", + } + if not isinstance(response, dict) or set(response) != {"artifact_sha256", "artifact_base64"}: + raise TopicInfluenceInvalidResponse("topic influence result shape is invalid") + artifact_sha256 = response["artifact_sha256"] + encoded = response["artifact_base64"] + if not _SHA256.fullmatch(str(artifact_sha256)) or not isinstance(encoded, str): + raise TopicInfluenceInvalidResponse("topic influence artifact envelope is invalid") + try: + artifact_bytes = base64.b64decode(encoded, validate=True) + except binascii.Error as exc: + raise TopicInfluenceInvalidResponse("topic influence artifact bytes are invalid") from exc + if hashlib.sha256(artifact_bytes).hexdigest() != artifact_sha256: + raise TopicInfluenceInvalidResponse("topic influence artifact digest is invalid") + try: + decoded = json.loads(artifact_bytes) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise TopicInfluenceInvalidResponse("topic influence artifact bytes are invalid") from exc + if not isinstance(decoded, dict) or set(decoded) != required: + raise TopicInfluenceInvalidResponse("topic influence result shape is invalid") + response = decoded + tepp = request.payload["tepp_run"] + if ( + response["schema_version"] != RESULT_SCHEMA_VERSION + or response["request_sha256"] != request.request_sha256 + or response["tepp_run_id"] != tepp["tepp_run_id"] + or response["source_snapshot_sha256"] != tepp["source_snapshot_sha256"] + or response["knowledge_cutoff"] != tepp["knowledge_cutoff"] + or response["membership_fingerprint_sha256"] + != request.membership_fingerprint_sha256 + or response["posterior_draw_coverage"] != tepp["posterior_draw_count"] + or response["convergence_status_code"] != "converged" + or response["identification_status_code"] != "identified" + or response["parity_status_code"] != "passed" + or response["compute_backend_code"] not in {"rust_cpu", "rust_gpu"} + or response["precision_code"] not in {"f64", "f32"} + or not _REVISION.fullmatch(str(response["code_revision"])) + or not isinstance(response["producer_version"], str) + or not response["producer_version"].strip() + ): + raise TopicInfluenceInvalidResponse("topic influence result binding is invalid") + expected = { + (observation["post_id"], membership["membership_id"], topic) + for observation in request.payload["observations"] + for membership in observation["memberships"] + for topic in request.payload["topic_indices"] + } + actual: set[tuple[str, str, int]] = set() + influences = response["influences"] + if not isinstance(influences, list): + raise TopicInfluenceInvalidResponse("topic influence rows are invalid") + for influence in influences: + if not isinstance(influence, dict) or set(influence) != { + "post_id", + "membership_id", + "topic_index", + "influence_value", + "uncertainty_method_code", + "uncertainty_lower_value", + "uncertainty_upper_value", + "diagnostic_status_code", + }: + raise TopicInfluenceInvalidResponse("topic influence row shape is invalid") + key = (influence["post_id"], influence["membership_id"], influence["topic_index"]) + values = ( + influence["influence_value"], + influence["uncertainty_lower_value"], + influence["uncertainty_upper_value"], + ) + if ( + key in actual + or any(type(value) not in {int, float} or not math.isfinite(value) for value in values) + or values[0] < 0 + or values[1] < 0 + or values[2] < values[1] + or influence["diagnostic_status_code"] != "accepted" + or not isinstance(influence["uncertainty_method_code"], str) + or not influence["uncertainty_method_code"].strip() + ): + raise TopicInfluenceInvalidResponse("topic influence row evidence is invalid") + actual.add(key) + if actual != expected: + raise TopicInfluenceInvalidResponse("topic influence result is incomplete") + return TopicInfluenceResult({**response, "artifact_sha256": artifact_sha256}) + + +class TopicInfluenceClient: + """Submit one request to a configured fast-mlsirm service transport.""" + + available = True + + def __init__( + self, + transport: Callable[[dict[str, Any]], object], + *, + lease_timeout_seconds: int, + ) -> None: + if type(lease_timeout_seconds) is not int or lease_timeout_seconds <= 0: + raise ValueError("lease_timeout_seconds must be a positive integer") + self._transport = transport + self.lease_timeout_seconds = lease_timeout_seconds + + def estimate(self, request: TopicInfluenceRequest) -> TopicInfluenceResult: + """Return only a request-bound, complete result envelope.""" + return validate_topic_influence_result(request, self._transport(request.to_json())) + + +class HttpTopicInfluenceClient(TopicInfluenceClient): + """Use the owner service's versioned topic-influence endpoint.""" + + def __init__( + self, + base_url: str, + api_key: str, + *, + timeout: float, + lease_timeout_seconds: int, + ) -> None: + if not base_url.strip(): + raise TopicInfluenceNotAvailable("fast-mlsirm topic influence is unavailable") + if ( + type(timeout) not in {int, float} + or not math.isfinite(timeout) + or timeout <= 0 + ): + raise ValueError("timeout must be a positive finite number") + url = f"{base_url.rstrip('/')}/v1/topic-context-influence" + super().__init__( + lambda payload: post_json( + url, + payload, + headers={"authorization": f"Bearer {api_key}"} if api_key else {}, + timeout=timeout, + service_peer_name="fast-mlsirm", + ), + lease_timeout_seconds=lease_timeout_seconds, + ) diff --git a/migrations/0257_public_claim_envelope.sql b/migrations/0257_public_claim_envelope.sql new file mode 100644 index 000000000..ef4baaf3f --- /dev/null +++ b/migrations/0257_public_claim_envelope.sql @@ -0,0 +1,93 @@ +-- Migration 0257: provenance-bearing public-claim admission envelope. +-- Replay-safe under ADR 0166. Verification opt-in remains on global_ask_job. + +insert into common_lookup_value + (lookup_category, lookup_code, lookup_label, display_order) +values + ('public_claim_kind', 'claim_organization_presence', 'Organization presence', 0), + ('public_claim_kind', 'claim_public_event', 'Public event', 1), + ('public_claim_kind', 'claim_public_relationship', 'Public relationship', 2) +on conflict (lookup_code) do nothing; + +create table if not exists public_claim_envelope ( + public_claim_envelope_id uuid primary key default uuid_generate_v4(), + source_post_id uuid not null references source_post (post_id), + provenance_assertion_id uuid not null references provenance_assertion (assertion_id), + claim_kind_code text not null references common_lookup_value (lookup_code), + claim_text text not null, + egress_eligible boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (source_post_id, claim_kind_code, claim_text), + check (char_length(btrim(claim_text)) between 1 and 800) +); + +create index if not exists public_claim_envelope_egress_idx + on public_claim_envelope (source_post_id, created_at) + where egress_eligible; + +create or replace function validate_public_claim_envelope() +returns trigger +language plpgsql +as $$ +declare + visibility text; + claim_category text; + evidence_post_id uuid; + provenance_relation text; +begin + select lookup_category into claim_category + from common_lookup_value where lookup_code = new.claim_kind_code; + if claim_category is distinct from 'public_claim_kind' then + raise exception 'public_claim_kind_required'; + end if; + + select post.visibility_code into visibility + from source_post post where post.post_id = new.source_post_id; + select assertion.relation_code, + case + when count(binding.node_id) = 1 + then (array_agg(binding.node_id))[1] + end + into provenance_relation, evidence_post_id + from provenance_assertion assertion + left join provenance_resource_binding binding + on binding.resource_id = assertion.object_resource_id + and binding.node_type_code = 'node_post' + where assertion.assertion_id = new.provenance_assertion_id + group by assertion.relation_code; + + if new.egress_eligible and visibility is distinct from 'public' then + raise exception 'public_claim_requires_public_post'; + end if; + if provenance_relation is distinct from 'prov_was_derived_from' + or evidence_post_id is distinct from new.source_post_id then + raise exception 'public_claim_requires_source_post_provenance'; + end if; + return new; +end; +$$; + +drop trigger if exists validate_public_claim_envelope on public_claim_envelope; +create trigger validate_public_claim_envelope + before insert or update on public_claim_envelope + for each row execute function validate_public_claim_envelope(); + +create or replace function revoke_private_public_claim_envelopes() +returns trigger +language plpgsql +as $$ +begin + if old.visibility_code = 'public' and new.visibility_code <> 'public' then + update public_claim_envelope + set egress_eligible = false, updated_at = now() + where source_post_id = new.post_id and egress_eligible; + end if; + return new; +end; +$$; + +drop trigger if exists revoke_private_public_claim_envelopes on source_post; +create trigger revoke_private_public_claim_envelopes + after update of visibility_code on source_post + for each row execute function revoke_private_public_claim_envelopes(); diff --git a/migrations/0258_post_content_backfill_candidate_index.sql b/migrations/0258_post_content_backfill_candidate_index.sql new file mode 100644 index 000000000..86e4afbcd --- /dev/null +++ b/migrations/0258_post_content_backfill_candidate_index.sql @@ -0,0 +1,9 @@ +-- Migration 0258 / ADR 0098: stop the bounded backfill scan in source order. +create index if not exists source_post_content_backfill_candidate_idx + on source_post ( + coalesce(event_occurred_at, created_at), + created_at, + post_id + ) + where nullif(btrim(source_draft_code), '') is null + and nullif(btrim(source_deleted_flag), '') is null; diff --git a/migrations/0259_project_journey_temporal_artifact.sql b/migrations/0259_project_journey_temporal_artifact.sql new file mode 100644 index 000000000..4529be923 --- /dev/null +++ b/migrations/0259_project_journey_temporal_artifact.sql @@ -0,0 +1,52 @@ +-- Digest-bound temporal evidence admitted only for existing Event Lineage edges. +create table if not exists project_journey_temporal_artifact ( + analysis_run_id uuid primary key references analysis_run_tepp_result(analysis_run_id) on delete cascade, + remote_run_id text not null, + schema_version text not null check (schema_version = 'tepp.tdt_chronos_interval_consistency.v1'), + snapshot_id text not null check (btrim(snapshot_id) <> ''), + input_digest_sha256 text not null check (input_digest_sha256 ~ '^[0-9a-f]{64}$'), + artifact_digest_sha256 text not null unique check (artifact_digest_sha256 ~ '^[0-9a-f]{64}$'), + admitted_at timestamptz not null default clock_timestamp(), + unique (analysis_run_id, remote_run_id) +); + +create table if not exists project_journey_temporal_relation ( + analysis_run_id uuid not null references project_journey_temporal_artifact(analysis_run_id) on delete cascade, + left_post_id uuid not null references source_post(post_id) on delete cascade, + right_post_id uuid not null references source_post(post_id) on delete cascade, + observed boolean not null, + primary key (analysis_run_id, left_post_id, right_post_id), + foreign key (left_post_id, right_post_id) + references post_lineage_edge(parent_post_id, child_post_id) on delete cascade, + check (left_post_id <> right_post_id) +); + +create table if not exists project_journey_temporal_relation_kind ( + analysis_run_id uuid not null, + left_post_id uuid not null, + right_post_id uuid not null, + relation_code text not null check (relation_code in ( + 'before', 'after', 'meets', 'met_by', 'overlaps', 'overlapped_by', + 'starts', 'started_by', 'during', 'contains', 'finishes', 'finished_by', 'equals' + )), + relation_ordinal smallint not null check (relation_ordinal between 0 and 12), + primary key (analysis_run_id, left_post_id, right_post_id, relation_code), + unique (analysis_run_id, left_post_id, right_post_id, relation_ordinal), + foreign key (analysis_run_id, left_post_id, right_post_id) + references project_journey_temporal_relation(analysis_run_id, left_post_id, right_post_id) + on delete cascade +); + +create table if not exists project_journey_temporal_support ( + analysis_run_id uuid not null, + left_post_id uuid not null, + right_post_id uuid not null, + assertion_ordinal integer not null check (assertion_ordinal >= 0), + primary key (analysis_run_id, left_post_id, right_post_id, assertion_ordinal), + foreign key (analysis_run_id, left_post_id, right_post_id) + references project_journey_temporal_relation(analysis_run_id, left_post_id, right_post_id) + on delete cascade +); + +create index if not exists project_journey_temporal_relation_right_idx + on project_journey_temporal_relation (right_post_id, left_post_id, analysis_run_id); diff --git a/migrations/0260_topic_influence_job.sql b/migrations/0260_topic_influence_job.sql new file mode 100644 index 000000000..e2d918c4a --- /dev/null +++ b/migrations/0260_topic_influence_job.sql @@ -0,0 +1,232 @@ +-- ADR 0210: durable producer lease for the external fast-mlsirm result. +-- The job carries no scores and never substitutes for a producer artifact. + +create table if not exists topic_influence_job ( + topic_model_run_id uuid primary key + references topic_model_run (topic_model_run_id) on delete cascade, + status_code text not null + check (status_code in ('queued', 'awaiting_evidence', 'running', 'succeeded', 'failed')), + request_sha256 text check (request_sha256 ~ '^[0-9a-f]{64}$'), + attempt_count integer not null default 0 check (attempt_count >= 0), + failure_code text check ( + failure_code is null or failure_code in ( + 'input_evidence_incomplete', + 'producer_unavailable', + 'producer_result_invalid', + 'persistence_failed' + ) + ), + queued_at timestamptz not null default clock_timestamp(), + not_before timestamptz not null default clock_timestamp(), + started_at timestamptz, + lease_expires_at timestamptz, + lease_token uuid, + completed_at timestamptz, + check ( + (status_code = 'queued' and started_at is null and lease_expires_at is null and lease_token is null and completed_at is null) + or (status_code = 'awaiting_evidence' and started_at is null and lease_expires_at is null and lease_token is null and completed_at is not null) + or (status_code = 'running' and started_at is not null and lease_expires_at is not null and lease_token is not null and completed_at is null) + or (status_code in ('succeeded', 'failed') and started_at is not null and lease_expires_at is null and lease_token is null and completed_at is not null) + ) +); + +alter table topic_influence_job + add column if not exists lease_expires_at timestamptz, + add column if not exists lease_token uuid; + +alter table topic_influence_job + drop constraint if exists topic_influence_job_status_code_check, + drop constraint if exists topic_influence_job_check; + +-- A pre-lease branch deployment cannot supply a declared expiry after the +-- fact. Release that interrupted claim; the next worker claim records the +-- configured lease contract before invoking the producer. +update topic_influence_job + set status_code = 'queued', request_sha256 = null, started_at = null, + completed_at = null, failure_code = null, + not_before = clock_timestamp(), + lease_expires_at = null, lease_token = null + where status_code = 'running' + and (lease_expires_at is null or lease_token is null); + +alter table topic_influence_job + add constraint topic_influence_job_status_code_check + check (status_code in ( + 'queued', 'awaiting_evidence', 'running', 'succeeded', 'failed' + )), + add constraint topic_influence_job_check check ( + (status_code = 'queued' + and started_at is null + and lease_expires_at is null + and lease_token is null + and completed_at is null) + or (status_code = 'awaiting_evidence' + and started_at is null + and lease_expires_at is null + and lease_token is null + and completed_at is not null) + or (status_code = 'running' + and started_at is not null + and lease_expires_at is not null + and lease_token is not null + and completed_at is null) + or (status_code in ('succeeded', 'failed') + and started_at is not null + and lease_expires_at is null + and lease_token is null + and completed_at is not null) + ); + +create index if not exists topic_influence_job_queue_idx + on topic_influence_job (status_code, not_before, queued_at, topic_model_run_id) + where status_code = 'queued'; + +create or replace function queue_topic_influence_job() +returns trigger +language plpgsql +as $$ +begin + insert into topic_influence_job (topic_model_run_id, status_code) + values (new.topic_model_run_id, 'queued') + on conflict (topic_model_run_id) do nothing; + return new; +end +$$; + +create or replace function wake_topic_influence_job_for_model() +returns trigger +language plpgsql +as $$ +begin + update topic_influence_job + set status_code = 'queued', failure_code = null, completed_at = null, + not_before = clock_timestamp() + where topic_model_run_id = new.topic_model_run_id + and status_code = 'awaiting_evidence'; + return new; +end +$$; + +create or replace function wake_topic_influence_job_for_analysis() +returns trigger +language plpgsql +as $$ +begin + update topic_influence_job job + set status_code = 'queued', failure_code = null, completed_at = null, + not_before = clock_timestamp() + from topic_model_run model + where model.analysis_run_id = new.analysis_run_id + and job.topic_model_run_id = model.topic_model_run_id + and job.status_code = 'awaiting_evidence'; + return new; +end +$$; + +drop trigger if exists topic_model_run_influence_queue on topic_model_run; +create trigger topic_model_run_influence_queue +after insert on topic_model_run +for each row execute function queue_topic_influence_job(); + +drop trigger if exists topic_model_run_influence_wake on topic_model_run; +create trigger topic_model_run_influence_wake after update on topic_model_run +for each row execute function wake_topic_influence_job_for_model(); + +drop trigger if exists analysis_run_influence_wake on analysis_run; +create trigger analysis_run_influence_wake +after update of knowledge_cutoff, analysis_source_snapshot_id on analysis_run +for each row execute function wake_topic_influence_job_for_analysis(); + +drop trigger if exists topic_coordinate_influence_wake on topic_post_coordinate; +create trigger topic_coordinate_influence_wake +after insert or update on topic_post_coordinate +for each row execute function wake_topic_influence_job_for_model(); + +drop trigger if exists topic_membership_influence_wake on topic_context_membership; +create trigger topic_membership_influence_wake +after insert or update on topic_context_membership +for each row execute function wake_topic_influence_job_for_model(); + +drop trigger if exists topic_definition_influence_wake on topic_definition; +create trigger topic_definition_influence_wake +after insert or update on topic_definition +for each row execute function wake_topic_influence_job_for_model(); + +create or replace function wake_topic_influence_job_for_provenance_binding() +returns trigger +language plpgsql +as $$ +begin + update topic_influence_job job + set status_code = 'queued', failure_code = null, completed_at = null, + not_before = clock_timestamp() + from topic_context_membership membership + join provenance_assertion assertion + on assertion.assertion_id = membership.provenance_assertion_id + and assertion.relation_code = 'prov_was_derived_from' + where assertion.object_resource_id = new.resource_id + and new.node_type_code = 'node_post' + and membership.source_post_id = new.node_id + and job.topic_model_run_id = membership.topic_model_run_id + and job.status_code = 'awaiting_evidence'; + if tg_op = 'UPDATE' then + update topic_influence_job job + set status_code = 'queued', failure_code = null, completed_at = null, + not_before = clock_timestamp() + from topic_context_membership membership + join provenance_assertion assertion + on assertion.assertion_id = membership.provenance_assertion_id + and assertion.relation_code = 'prov_was_derived_from' + where assertion.object_resource_id = old.resource_id + and old.node_type_code = 'node_post' + and membership.source_post_id = old.node_id + and job.topic_model_run_id = membership.topic_model_run_id + and job.status_code = 'awaiting_evidence'; + end if; + return new; +end +$$; + +drop trigger if exists topic_provenance_binding_influence_wake + on provenance_resource_binding; +create trigger topic_provenance_binding_influence_wake +after insert or update on provenance_resource_binding +for each row execute function wake_topic_influence_job_for_provenance_binding(); + +create or replace function wake_topic_influence_job_for_provenance_assertion() +returns trigger +language plpgsql +as $$ +begin + update topic_influence_job job + set status_code = 'queued', failure_code = null, completed_at = null, + not_before = clock_timestamp() + from topic_context_membership membership + where membership.provenance_assertion_id = new.assertion_id + and job.topic_model_run_id = membership.topic_model_run_id + and job.status_code = 'awaiting_evidence'; + return new; +end +$$; + +drop trigger if exists topic_provenance_assertion_influence_wake + on provenance_assertion; +create trigger topic_provenance_assertion_influence_wake +after update of object_resource_id, relation_code on provenance_assertion +for each row execute function wake_topic_influence_job_for_provenance_assertion(); + +-- Older topic-lineage envelopes and calibrated-measurement receipts are not +-- the accepted posterior projection. Remove candidate triggers that could +-- wake this queue from those scientifically distinct records. +drop trigger if exists topic_tepp_receipt_influence_wake on analysis_run_tepp_receipt; +drop trigger if exists topic_terminal_influence_wake on analysis_run_topic_lineage_result; + +insert into topic_influence_job (topic_model_run_id, status_code) +select model.topic_model_run_id, 'queued' + from topic_model_run model + where not exists ( + select 1 + from topic_influence_run influence + where influence.topic_model_run_id = model.topic_model_run_id + ) +on conflict (topic_model_run_id) do nothing; diff --git a/migrations/rollback/0257_public_claim_envelope.sql b/migrations/rollback/0257_public_claim_envelope.sql new file mode 100644 index 000000000..5d71cfc6c --- /dev/null +++ b/migrations/rollback/0257_public_claim_envelope.sql @@ -0,0 +1,5 @@ +drop trigger if exists revoke_private_public_claim_envelopes on source_post; +drop function if exists revoke_private_public_claim_envelopes(); +drop trigger if exists validate_public_claim_envelope on public_claim_envelope; +drop function if exists validate_public_claim_envelope(); +drop table if exists public_claim_envelope; diff --git a/scripts/accept_operations_dashboard_runtime.sh b/scripts/accept_operations_dashboard_runtime.sh index 4c01cbe86..0f1174248 100755 --- a/scripts/accept_operations_dashboard_runtime.sh +++ b/scripts/accept_operations_dashboard_runtime.sh @@ -5,37 +5,60 @@ export COMPOSE_FILE=docker-compose.yml : "${ALLOW_PROVIDER_CALLS:?Set ALLOW_PROVIDER_CALLS=1 only after the readiness-lease fix is deployed}" : "${EXPECTED_ORCHESTRATOR_REVISION:?Set the exact merged contextual-orchestrator revision}" : "${EXPECTED_LINEAGEWEAVE_REVISION:?Set the exact LineageWeave revision used for the images}" -: "${ORCHESTRATOR_ADMIN_TOKEN:?Set the runtime admin token}" : "${LINEAGEWEAVE_ACCESS_TOKEN:?Set an authorized post_admin access token}" : "${LINEAGEWEAVE_OIDC_ISSUER:?Set the frontend OIDC issuer}" : "${LINEAGEWEAVE_OIDC_CLIENT_ID:?Set the frontend OIDC client id}" +: "${LINEAGEWEAVE_RUNTIME_ASK_QUESTION:?Set one non-identifying runtime Ask question}" +: "${LINEAGEWEAVE_RUNTIME_ASK_TIMEOUT_SECONDS:?Set the declared runtime Ask observation budget}" : "${K6_VUS:?Set the declared Dashboard concurrency}" : "${K6_DURATION:?Set the declared Dashboard observation duration, including its unit}" : "${BACKEND_READINESS_TIMEOUT_SECONDS:?Set the declared backend readiness budget}" +: "${ORCHESTRATOR_PROBE_TIMEOUT_SECONDS:?Set the declared per-agent provider probe timeout (0.1 through 30 seconds)}" +: "${ORCHESTRATOR_READINESS_TIMEOUT_SECONDS:?Set the declared readiness-job observation budget}" +: "${OPERATIONS_CASE_ACCEPTANCE_TIMEOUT_SECONDS:?Set the declared operations-case observation budget}" +: "${OPERATIONS_CASE_POLL_SECONDS:?Set the declared operations-case observation cadence}" +[[ ",${COMPOSE_PROFILES:-}," == *,mcp,* ]] || { + echo "start the accepted stack with COMPOSE_PROFILES=mcp so MCP evidence is included" >&2 + exit 2 +} [[ "$ALLOW_PROVIDER_CALLS" == "1" ]] || { echo "provider calls are not authorized" >&2; exit 2; } [[ "$EXPECTED_LINEAGEWEAVE_REVISION" =~ ^[0-9a-f]{40}$ ]] || { echo "EXPECTED_LINEAGEWEAVE_REVISION must be a full commit SHA" >&2 exit 2 } -ORCHESTRATOR_URL="${ORCHESTRATOR_URL:-http://localhost:18000}" BACKEND_URL="${BACKEND_URL:-http://localhost:18420}" LINEAGEWEAVE_E2E_BASE_URL="${LINEAGEWEAVE_E2E_BASE_URL:-http://localhost:15173}" POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-lineageweave-postgres-1}" SCREENSHOT_DESKTOP_PATH="${SCREENSHOT_DESKTOP_PATH:-/tmp/lineageweave-operations-dashboard-runtime-desktop.png}" SCREENSHOT_MOBILE_PATH="${SCREENSHOT_MOBILE_PATH:-/tmp/lineageweave-operations-dashboard-runtime-mobile.png}" +ASK_SCREENSHOT_DESKTOP_PATH="${ASK_SCREENSHOT_DESKTOP_PATH:-/tmp/lineageweave-ask-runtime-desktop.png}" +ASK_SCREENSHOT_MOBILE_PATH="${ASK_SCREENSHOT_MOBILE_PATH:-/tmp/lineageweave-ask-runtime-mobile.png}" E2E_OUTPUT_DIR="${E2E_OUTPUT_DIR:-/tmp/lineageweave-operations-dashboard-e2e}" K6_SUMMARY_PATH="${K6_SUMMARY_PATH:-/tmp/lineageweave-operations-dashboard-k6.json}" repository_root="$(git rev-parse --show-toplevel)" -for screenshot_path in "$SCREENSHOT_DESKTOP_PATH" "$SCREENSHOT_MOBILE_PATH"; do +screenshot_paths=("$SCREENSHOT_DESKTOP_PATH" "$SCREENSHOT_MOBILE_PATH" "$ASK_SCREENSHOT_DESKTOP_PATH" "$ASK_SCREENSHOT_MOBILE_PATH") +for screenshot_path in "${screenshot_paths[@]}"; do case "$screenshot_path" in "$repository_root"/*) echo "runtime screenshots must stay outside the repository" >&2; exit 2 ;; esac done +for ((left_index = 0; left_index < ${#screenshot_paths[@]}; left_index++)); do + for ((right_index = left_index + 1; right_index < ${#screenshot_paths[@]}; right_index++)); do + [[ "${screenshot_paths[$left_index]}" != "${screenshot_paths[$right_index]}" ]] || { + echo "runtime screenshots require four distinct paths" >&2 + exit 2 + } + done +done [[ "$SCREENSHOT_DESKTOP_PATH" != "$SCREENSHOT_MOBILE_PATH" ]] || { echo "desktop and mobile screenshots require distinct paths" >&2 exit 2 } +[[ "$ASK_SCREENSHOT_DESKTOP_PATH" != "$ASK_SCREENSHOT_MOBILE_PATH" ]] || { + echo "Ask desktop and mobile screenshots require distinct paths" >&2 + exit 2 +} case "$E2E_OUTPUT_DIR" in "$repository_root"/*) echo "runtime browser artifacts must stay outside the repository" >&2; exit 2 ;; esac @@ -51,6 +74,27 @@ esac echo "BACKEND_READINESS_TIMEOUT_SECONDS must be a positive integer" >&2 exit 2 } +jq -en --arg value "$ORCHESTRATOR_PROBE_TIMEOUT_SECONDS" \ + '($value | tonumber) >= 0.1 and ($value | tonumber) <= 30' >/dev/null || { + echo "ORCHESTRATOR_PROBE_TIMEOUT_SECONDS must be between 0.1 and 30" >&2 + exit 2 +} +[[ "$ORCHESTRATOR_READINESS_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + echo "ORCHESTRATOR_READINESS_TIMEOUT_SECONDS must be a positive integer" >&2 + exit 2 +} +[[ "$OPERATIONS_CASE_ACCEPTANCE_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + echo "OPERATIONS_CASE_ACCEPTANCE_TIMEOUT_SECONDS must be a positive integer" >&2 + exit 2 +} +[[ "$OPERATIONS_CASE_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + echo "OPERATIONS_CASE_POLL_SECONDS must be a positive integer" >&2 + exit 2 +} +[[ "$LINEAGEWEAVE_RUNTIME_ASK_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + echo "LINEAGEWEAVE_RUNTIME_ASK_TIMEOUT_SECONDS must be a positive integer" >&2 + exit 2 +} for command_name in curl docker jq corepack k6 uv; do command -v "$command_name" >/dev/null || { echo "$command_name is required" >&2; exit 2; } @@ -61,13 +105,22 @@ actual_revision="$(docker inspect lineageweave-orchestrator-1 --format '{{ index echo "orchestrator image revision does not match the accepted revision" >&2 exit 2 } -for service_name in backend backend-worker frontend; do +docker inspect lineageweave-mcp-1 >/dev/null 2>&1 || { + echo "start the accepted stack with COMPOSE_PROFILES=mcp before running acceptance" >&2 + exit 2 +} +for service_name in backend backend-worker mcp frontend; do product_revision="$(docker inspect "lineageweave-${service_name}-1" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" [[ "$product_revision" == "$EXPECTED_LINEAGEWEAVE_REVISION" ]] || { echo "lineageweave-${service_name}-1 image revision does not match the accepted revision" >&2 exit 2 } done +worker_started_at="$(docker inspect lineageweave-backend-worker-1 --format '{{.State.StartedAt}}')" +[[ -n "$worker_started_at" && "$worker_started_at" != "0001-01-01T00:00:00Z" ]] || { + echo "backend worker has no exact deployment start instant" >&2 + exit 1 +} frontend_issuer="$(docker inspect lineageweave-frontend-1 --format '{{ index .Config.Labels "io.contextualwisdomlab.lineageweave.oidc-issuer" }}')" frontend_backend_url="$(docker inspect lineageweave-frontend-1 --format '{{ index .Config.Labels "io.contextualwisdomlab.lineageweave.backend-url" }}')" [[ "$frontend_issuer" == "$LINEAGEWEAVE_OIDC_ISSUER" ]] || { @@ -108,81 +161,200 @@ EOF fi } -# This explicit refresh is the first provider call. The revision gate above -# prevents an older runtime from reacquiring readiness work without the lease fix. -curl_json "$ORCHESTRATOR_ADMIN_TOKEN" GET \ - "$ORCHESTRATOR_URL/api/v1/provider_readiness/latest?refresh=true" \ - | jq -e '.status == "ready" and .ready_agent_count > 0' >/dev/null +orchestrator_json() { + local method="$1" path="$2" body="${3:-}" request_timeout_ms="$4" + docker exec -i \ + lineageweave-orchestrator-1 \ + python - "$method" "$path" "$body" "$request_timeout_ms" <<'PY' +import os +import sys +import urllib.request + +method, path, body, timeout_ms = sys.argv[1:] +headers = { + "Authorization": f"Bearer {os.environ['CONTEXTUAL_ORCHESTRATOR_TOKEN']}" +} +data = None +if body: + headers["Content-Type"] = "application/json" + data = body.encode("utf-8") +if timeout_ms: + headers["X-Request-Timeout-Ms"] = timeout_ms +request = urllib.request.Request( + f"http://127.0.0.1:8000{path}", + data=data, + headers=headers, + method=method, +) +with urllib.request.urlopen(request, timeout=max(float(timeout_ms) / 1000, 1.0)) as response: + sys.stdout.write(response.read().decode("utf-8")) +PY +} + +# This explicit bounded refresh is the first provider call. Read the cached +# catalog first and probe only active agents from the configured gateway. +readiness_deadline=$((SECONDS + ORCHESTRATOR_READINESS_TIMEOUT_SECONDS)) +remaining_readiness_ms() { + local remaining_seconds=$((readiness_deadline - SECONDS)) + (( remaining_seconds > 0 )) || return 1 + printf '%d' "$((remaining_seconds * 1000))" +} +readiness_timeout_ms="$(remaining_readiness_ms)" || { + echo "provider readiness exhausted its declared observation budget before catalog read" >&2 + exit 1 +} +cached_readiness="$(orchestrator_json GET \ + /api/v1/provider_readiness/latest "" "$readiness_timeout_ms")" +configured_agent_ids="$(jq -ce \ + '[.items[] | select(.provider == "configured_gateway" and .status != "disabled") | .agent_id] | unique | select(length > 0)' \ + <<<"$cached_readiness")" || { + echo "no active configured-gateway agents are available for readiness verification" >&2 + exit 1 +} +readiness_request="$(jq -cn \ + --argjson agent_ids "$configured_agent_ids" \ + --argjson timeout_seconds "$ORCHESTRATOR_PROBE_TIMEOUT_SECONDS" \ + '{agent_ids:$agent_ids,capability_code:"structured",timeout_seconds:$timeout_seconds}')" +readiness_timeout_ms="$(remaining_readiness_ms)" || { + echo "provider readiness exhausted its declared observation budget before job submission" >&2 + exit 1 +} +readiness_job="$(orchestrator_json POST \ + /api/v1/provider_readiness_refreshes "$readiness_request" "$readiness_timeout_ms")" +readiness_job_id="$(jq -er '.job_id | select(type == "string" and length > 0)' \ + <<<"$readiness_job")" +while (( SECONDS < readiness_deadline )); do + readiness_status="$(jq -er '.status' <<<"$readiness_job")" + case "$readiness_status" in + completed) + jq -e '.ready_count > 0' <<<"$readiness_job" >/dev/null || { + echo "provider readiness completed without an available configured-gateway agent" >&2 + exit 1 + } + break + ;; + queued|running) + readiness_poll_after_ms="$(jq -er \ + '.poll_after_ms | select(type == "number" and floor == . and . > 0)' \ + <<<"$readiness_job")" || { + echo "provider readiness did not declare a valid polling cadence" >&2 + exit 1 + } + readiness_timeout_ms="$(remaining_readiness_ms)" || break + (( readiness_poll_after_ms < readiness_timeout_ms )) || break + readiness_poll_seconds="$(jq -nr \ + --argjson poll_after_ms "$readiness_poll_after_ms" \ + '$poll_after_ms / 1000')" + sleep "$readiness_poll_seconds" + readiness_timeout_ms="$(remaining_readiness_ms)" || break + readiness_job="$(orchestrator_json GET \ + "/api/v1/provider_readiness_refreshes/$readiness_job_id" "" "$readiness_timeout_ms")" + ;; + failed|cancelled|expired) + echo "provider readiness ended before an agent became available; restore access and rerun acceptance" >&2 + exit 1 + ;; + *) + echo "provider readiness returned an unsupported job state" >&2 + exit 1 + ;; + esac +done +[[ "${readiness_status:-}" == "completed" ]] || { + echo "provider readiness did not complete within the declared observation budget" >&2 + exit 1 +} aggregate_sql=" -with preferred as ( - select post.post_id +with eligible_jobs as materialized ( + select post.post_id, job.source_body_sha256, job.status_code from source_post post join post_content_ingestion_job job on job.post_id = post.post_id where ${source_post_eligibility_sql} - and job.status_code = 'post_content_ingestion_succeeded' and exists ( select 1 from post_project_mention project where project.post_id = post.post_id and nullif(btrim(project.ontology_iri), '') is not null ) +), inflight as ( + select job.post_id + from eligible_jobs job + where job.status_code in ( + 'post_content_ingestion_queued', + 'post_content_ingestion_running' + ) and not exists ( select 1 from operations_case_analysis analysis - where analysis.post_id = post.post_id + where analysis.post_id = job.post_id and analysis.source_body_sha256 = job.source_body_sha256 ) -), grounded as ( - select distinct classification.post_id, classification.case_kind_code - from operations_case_classification classification - where nullif(btrim(classification.evidence_text), '') is not null - and classification.evidence_post_id is not null - and classification.evidence_input_sha256 is not null +), deployed_analyses as ( + select analysis.post_id + from eligible_jobs job + join operations_case_analysis analysis + on analysis.post_id = job.post_id + and analysis.source_body_sha256 = job.source_body_sha256 + where analysis.analyzed_at >= :'deployment_started_at'::timestamptz +), deployed_grounded as ( + select analysis.post_id + from deployed_analyses analysis + where exists ( + select 1 from operations_case_classification classification + where classification.post_id = analysis.post_id + and nullif(btrim(classification.evidence_text), '') is not null + and classification.evidence_post_id is not null + and classification.evidence_input_sha256 is not null + ) ) -select (select count(*) from preferred), - (select count(*) from operations_case_analysis), - (select count(*) from grounded); +select (select count(distinct post_id) from inflight), + (select count(distinct post_id) from deployed_analyses), + (select count(distinct post_id) from deployed_grounded); " -IFS='|' read -r preferred_before analysis_before grounded_before <<<"$( - docker exec "$POSTGRES_CONTAINER" psql -X -U lineageweave -d lineageweave \ - -AtF '|' -c "$aggregate_sql" -)" -[[ "$preferred_before" == "1" ]] || { - echo "expected exactly one normalized preferred candidate; observed $preferred_before" >&2 - exit 1 +run_operations_case_aggregate() { + printf '%s\n' "$aggregate_sql" \ + | docker exec -i "$POSTGRES_CONTAINER" \ + psql -X -U lineageweave -d lineageweave \ + -v deployment_started_at="$worker_started_at" -AtF '|' } -curl_json "$LINEAGEWEAVE_ACCESS_TOKEN" POST \ - "$BACKEND_URL/api/post-content/backfill" '{"limit":1}' \ - | jq -e '.selected_posts == 1 and .queued_posts == 1' >/dev/null - -deadline=$((SECONDS + 600)) -while (( SECONDS < deadline )); do - IFS='|' read -r preferred_after analysis_after grounded_after <<<"$( - docker exec "$POSTGRES_CONTAINER" psql -X -U lineageweave -d lineageweave \ - -AtF '|' -c "$aggregate_sql" - )" - if [[ "$preferred_after" == "0" \ - && "$analysis_after" -gt "$analysis_before" \ - && "$grounded_after" -gt "$grounded_before" ]]; then - break - fi - sleep 2 -done -[[ "${preferred_after:-1}" == "0" \ - && "${analysis_after:-0}" -gt "$analysis_before" \ - && "${grounded_after:-0}" -gt "$grounded_before" ]] || { - echo "grounded operations-case acceptance did not complete before the deadline" >&2 - exit 1 -} +IFS='|' read -r inflight_before analysis_before grounded_before <<<"$( + run_operations_case_aggregate +)" +if (( grounded_before > 0 )); then + inflight_after="$inflight_before" + analysis_after="$analysis_before" + grounded_after="$grounded_before" +else + (( inflight_before > 0 )) || { + echo "no deployment-grounded analysis or active eligible candidate is available" >&2 + exit 1 + } + deadline=$((SECONDS + OPERATIONS_CASE_ACCEPTANCE_TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + IFS='|' read -r inflight_after analysis_after grounded_after <<<"$( + run_operations_case_aggregate + )" + if (( analysis_after > analysis_before && grounded_after > grounded_before )); then + break + fi + sleep "$OPERATIONS_CASE_POLL_SECONDS" + done + (( ${analysis_after:-0} > analysis_before \ + && ${grounded_after:-0} > grounded_before )) || { + echo "grounded operations-case acceptance did not complete before the deadline" >&2 + exit 1 + } +fi curl_json "$LINEAGEWEAVE_ACCESS_TOKEN" GET "$BACKEND_URL/api/dashboard" \ | jq -e '.cases | length > 0' >/dev/null export LINEAGEWEAVE_ACCESS_TOKEN LINEAGEWEAVE_OIDC_ISSUER LINEAGEWEAVE_OIDC_CLIENT_ID export LINEAGEWEAVE_E2E_BASE_URL SCREENSHOT_DESKTOP_PATH SCREENSHOT_MOBILE_PATH +export ASK_SCREENSHOT_DESKTOP_PATH ASK_SCREENSHOT_MOBILE_PATH (cd frontend && corepack pnpm exec playwright test \ - e2e/runtime-operations-dashboard.spec.ts --output "$E2E_OUTPUT_DIR") + e2e/runtime-operations-dashboard.spec.ts e2e/runtime-ask-evidence.spec.ts --output "$E2E_OUTPUT_DIR") export BACKEND_URL LINEAGEWEAVE_ACCESS_TOKEN K6_VUS K6_DURATION k6 run --vus "$K6_VUS" --duration "$K6_DURATION" \ @@ -190,5 +362,5 @@ k6 run --vus "$K6_VUS" --duration "$K6_DURATION" \ jq -e '.metrics.checks.fails == 0 and .metrics.http_req_failed.value == 0' \ "$K6_SUMMARY_PATH" >/dev/null -printf 'operations-dashboard-runtime-acceptance-ok preferred=%s analysis_delta=%s grounded_delta=%s\n' \ - "$preferred_after" "$((analysis_after - analysis_before))" "$((grounded_after - grounded_before))" +printf 'operations-dashboard-runtime-acceptance-ok inflight=%s deployment_analysis=%s deployment_grounded=%s\n' \ + "$inflight_after" "$analysis_after" "$grounded_after" diff --git a/tests/test_analysis_run_worker.py b/tests/test_analysis_run_worker.py index 338a36478..0a9bf7141 100644 --- a/tests/test_analysis_run_worker.py +++ b/tests/test_analysis_run_worker.py @@ -214,6 +214,9 @@ async def fake_process(_pool, **kwargs): monkeypatch.setattr(post_content_worker, "process_post_content_job", fake_process) class MalformedValkey: + def __init__(self) -> None: + self.trimmed: list[tuple[str, str, bool]] = [] + async def xread(self, _streams, *, count, block): assert (count, block) == (10, 1000) return [ @@ -232,14 +235,22 @@ async def xread(self, _streams, *, count, block): ) ] + async def xtrim(self, key, *, minid, approximate): + self.trimmed.append((key, minid, approximate)) + return 2 + + client = MalformedValkey() assert await post_content_worker.consume_post_content_stream_once( - MalformedValkey(), + client, _Pool(), last_id="0-0", vision_factory=lambda: None, embedding_factory=lambda: None, structure_factory=lambda: None, ) == "1-1" + assert client.trimmed == [ + (post_content_worker.POST_CONTENT_STREAM_KEY, "1-2", False) + ] assert calls[0]["post_id"] == "00000000-0000-0000-0000-000000000001" assert calls[0]["source_body_digest"] == "a" * 64 diff --git a/tests/test_backend_worker_process.py b/tests/test_backend_worker_process.py index 3db25bd15..958953cab 100644 --- a/tests/test_backend_worker_process.py +++ b/tests/test_backend_worker_process.py @@ -3,8 +3,11 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager from types import SimpleNamespace +import pytest + from backend.app import main, worker @@ -47,8 +50,14 @@ async def exercise() -> None: assert valkey.closed -def test_worker_process_owns_all_three_durable_consumers(monkeypatch) -> None: - """Analysis, post-content, and Global Ask queues share one worker owner.""" +@pytest.mark.parametrize( + ("topic_influence_url", "expects_topic_consumer"), + [("http://measurement.test", True), ("measurement.test/no-scheme", False)], +) +def test_worker_process_owns_all_configured_durable_consumers( + monkeypatch, topic_influence_url: str, expects_topic_consumer: bool +) -> None: + """Analysis, content, Ask, and configured influence work share one owner.""" pool = _Closable() valkey = _Closable() calls: list[str] = [] @@ -58,6 +67,11 @@ def test_worker_process_owns_all_three_durable_consumers(monkeypatch) -> None: valkey_url="valkey", tepp_transport_url="", tepp_api_key="", + topic_influence_transport_url=topic_influence_url, + topic_influence_api_key="synthetic-token", + topic_influence_request_timeout_seconds=11, + topic_influence_lease_timeout_seconds=17, + topic_influence_poll_seconds=13, orchestrator_answer_timeout_seconds=570.0, ) @@ -71,6 +85,16 @@ async def global_ask(*_args, **kwargs) -> None: monkeypatch.setattr(worker, "load_settings", lambda: settings) monkeypatch.setattr(worker, "create_pool", lambda _url: _async_value(pool)) monkeypatch.setattr(worker, "create_valkey_client", lambda _url: valkey) + + @asynccontextmanager + async def lease(_pool): + calls.append("lease_acquired") + try: + yield + finally: + calls.append("lease_released") + + monkeypatch.setattr(worker, "_single_worker_lease", lease) monkeypatch.setattr(worker, "configure_telemetry", lambda _name: None) monkeypatch.setattr(worker, "shutdown_telemetry", lambda: calls.append("shutdown")) monkeypatch.setattr(worker, "configured_tepp_client", lambda *_args: object()) @@ -93,17 +117,147 @@ async def global_ask(*_args, **kwargs) -> None: worker, "run_post_content_worker", lambda *a, **kw: called("content", *a, **kw) ) monkeypatch.setattr(worker, "run_global_ask_worker", global_ask) + monkeypatch.setattr( + worker, + "run_topic_influence_worker", + lambda *a, **kw: called("topic_influence", *a, **kw), + ) asyncio.run(worker.run_worker_process()) - assert calls[:3] == ["analysis", "content", "global_ask"] + assert calls[:4] == ["lease_acquired", "analysis", "content", "global_ask"] + assert ("topic_influence" in calls) is expects_topic_consumer assert global_ask_kwargs["semantic_query_factory"]() is semantic_client assert global_ask_kwargs["claim_verification_factory"]() is verification_client - assert calls[-1] == "shutdown" + assert calls[-2:] == ["lease_released", "shutdown"] assert pool.closed assert valkey.closed +def test_worker_process_lease_fails_closed_for_a_second_replica() -> None: + """A PostgreSQL session lease enforces the single stream-consumer contract.""" + calls: list[str] = [] + + class Connection: + async def fetchval(self, query: str, name: str) -> bool: + assert "hashtextextended($1, 0)" in query + assert name == worker._WORKER_LEASE_NAME + calls.append("unlock" if "unlock" in query else "lock") + return calls == ["lock"] + + class Acquire: + async def __aenter__(self): + return Connection() + + async def __aexit__(self, *_args): + return None + + class Pool: + def acquire(self): + return Acquire() + + async def accepted() -> None: + async with worker._single_worker_lease(Pool()): + calls.append("owned") + + asyncio.run(accepted()) + assert calls == ["lock", "owned", "unlock"] + + calls.clear() + + class RejectedConnection(Connection): + async def fetchval(self, query: str, name: str) -> bool: + assert "pg_try_advisory_lock" in query + assert name == worker._WORKER_LEASE_NAME + calls.append("rejected") + return False + + class RejectedAcquire(Acquire): + async def __aenter__(self): + return RejectedConnection() + + class RejectedPool(Pool): + def acquire(self): + return RejectedAcquire() + + async def rejected() -> None: + async with worker._single_worker_lease(RejectedPool()): + raise AssertionError("a second worker must not start") + + with pytest.raises(RuntimeError, match="already owns the lease"): + asyncio.run(rejected()) + assert calls == ["rejected"] + + +@pytest.mark.parametrize( + ("request_timeout", "lease_timeout"), + [(11, 11), (11, 10), (0, 17), (11, 0), (True, 17), (11, True)], +) +def test_topic_influence_lease_strictly_exceeds_request( + request_timeout: object, lease_timeout: object +) -> None: + """The declared lease retains time for persistence after request return.""" + settings = SimpleNamespace( + topic_influence_request_timeout_seconds=request_timeout, + topic_influence_lease_timeout_seconds=lease_timeout, + topic_influence_poll_seconds=13, + ) + + with pytest.raises(ValueError, match="strictly greater"): + worker._topic_influence_timeouts(settings) + + +def test_invalid_optional_topic_influence_config_is_isolated() -> None: + """Invalid optional measurement config cannot stop unrelated consumers.""" + settings = SimpleNamespace( + topic_influence_request_timeout_seconds=11, + topic_influence_lease_timeout_seconds=11, + topic_influence_poll_seconds=13, + ) + + assert ( + worker._optional_topic_influence_timeouts( + settings, transport_url="https://measurement.test" + ) + is None + ) + + +@pytest.mark.parametrize( + "transport_url", + ["measurement.test/no-scheme", "file:///tmp/socket", "https:///missing-host", 3], +) +def test_invalid_topic_influence_url_is_isolated(transport_url: object) -> None: + """Malformed optional endpoints cannot create a doomed consumer task.""" + settings = SimpleNamespace( + topic_influence_request_timeout_seconds=11, + topic_influence_lease_timeout_seconds=17, + topic_influence_poll_seconds=13, + ) + + assert ( + worker._optional_topic_influence_timeouts( + settings, transport_url=transport_url + ) + is None + ) + + +@pytest.mark.parametrize("poll_seconds", [None, 0, -1, 1.5, True]) +def test_topic_influence_poll_interval_must_be_declared( + poll_seconds: object, +) -> None: + """Claim retries cannot use an invented or invalid polling interval.""" + settings = SimpleNamespace( + topic_influence_request_timeout_seconds=11, + topic_influence_lease_timeout_seconds=17, + topic_influence_poll_seconds=poll_seconds, + ) + + with pytest.raises(ValueError, match="poll interval"): + worker._topic_influence_timeouts(settings) + + async def _async_value(value): """Return one test double through an awaitable seam.""" return value diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py index ad65a4c95..68519ba1b 100644 --- a/tests/test_contextual_orchestrator_start.py +++ b/tests/test_contextual_orchestrator_start.py @@ -65,7 +65,7 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None: module.main() -def test_bootstrap_leaves_embedding_selection_to_the_orchestrator(monkeypatch) -> None: +def test_bootstrap_delegates_embedding_discovery_upstream(monkeypatch) -> None: module = _load_start_module() captured: dict[str, object] = {} @@ -111,7 +111,7 @@ def serve() -> None: monkeypatch.setenv("BYTEZ_API_KEY", "bytez-key") monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token") monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example") - monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "embedding-model") + monkeypatch.setenv("BATCH_JOB_REGISTRY_VALKEY_URL", "redis://valkey:6379/1") module.main() @@ -121,6 +121,7 @@ def serve() -> None: assert "--embedding-model" not in argv assert captured["credentials"] == [ ("LLM_GATEWAY_API_KEY", "provider-key"), + ("batch_job_registry_valkey_url", "redis://valkey:6379/1"), ("OPENAI_API_KEY", "openai-key"), ("OPENROUTER_API_KEY", "openrouter-key"), ("NVIDIA_NIM_API_KEY", "nim-key"), @@ -135,8 +136,13 @@ def serve() -> None: "NVIDIA_NIM_API_KEY", "NVIDIA_NIM_API_KEY_SUB", "BYTEZ_API_KEY", + "BATCH_JOB_REGISTRY_VALKEY_URL", } & os.environ.keys() agents = captured["agents"] assert isinstance(agents, dict) assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] - assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ + assert agents["agents"][0]["provider_name"] == "configured_gateway" + assert agents["agents"][0]["base_url"] == "https://gateway.example/v1" + assert agents["agents"][0]["credential_key"] == "LLM_GATEWAY_API_KEY" + assert agents["agents"][0]["tags"] == ["bootstrap_seed"] + assert "--auto-discover-model-agents" in argv diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py index b287d3470..18255b7ed 100644 --- a/tests/test_documentation_hygiene.py +++ b/tests/test_documentation_hygiene.py @@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None: def test_orchestrator_runtime_pin_matches_adr() -> None: """The image pin and ADR must describe the same immutable upstream commit.""" - expected_embedding_contract_commit = "1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89" + expected_embedding_contract_commit = "3558a9a3aeb985282b255fcd80bb2201c19ae54b" dockerfile = ( _ROOT / "docker" / "contextual-orchestrator" / "Dockerfile" ).read_text(encoding="utf-8") @@ -131,3 +131,7 @@ def test_orchestrator_runtime_pin_matches_adr() -> None: assert adr_match is not None assert docker_match.group(1) == adr_match.group(1) assert docker_match.group(1) == expected_embedding_contract_commit + compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + assert f"-orchestrator:{expected_embedding_contract_commit}" in compose + assert "--checksum=sha256:" in dockerfile + assert "--require-hashes" in dockerfile diff --git a/tests/test_global_ask_queue.py b/tests/test_global_ask_queue.py index 8bca80b88..27d8ce94a 100644 --- a/tests/test_global_ask_queue.py +++ b/tests/test_global_ask_queue.py @@ -10,12 +10,21 @@ from backend.app.global_ask_queue import load_job_visibility from lineageweave import claim_verification as cv from lineageweave.post_chat import ChatAnswer, ChatSourceDocument +from lineageweave.public_claim_envelope import PersistedPublicClaimEnvelope class _AvailableClient: available = True +def test_public_claim_load_deduplicates_resource_bindings() -> None: + """A duplicate resource binding must not duplicate one admitted envelope.""" + sql = global_ask_queue._AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL.casefold() + assert "exists (" in sql + assert "from provenance_resource_binding evidence" in sql + assert "join provenance_resource_binding evidence" not in sql + + class _Connection: def __init__(self, row: dict[str, object] | None) -> None: self.row = row @@ -153,6 +162,14 @@ def test_public_verification_keeps_external_urls_out_of_internal_citations() -> ["public-post"], verify_external=True, client=client, + persisted_envelopes=( + PersistedPublicClaimEnvelope( + public_claim_envelope_id="envelope-1", + source_post_id="public-post", + claim_kind_code="claim_public_event", + claim_text="Synthetic public event.", + ), + ), ) ) @@ -162,6 +179,85 @@ def test_public_verification_keeps_external_urls_out_of_internal_citations() -> assert results[0].evidence[0].url not in results[0].source_post_ids +def test_persisted_envelope_is_production_admission_not_question_overlap() -> None: + """A stored cited envelope reaches the verifier without token nomination.""" + + client = _VerificationClient() + envelope = PersistedPublicClaimEnvelope( + public_claim_envelope_id="envelope-1", + source_post_id="public-post", + claim_kind_code="claim_public_event", + claim_text="Synthetic launch happened.", + ) + + status_code, results = asyncio.run( + global_ask_queue._verify_public_claims( + "A question with no overlapping words", + [], + ["public-post"], + verify_external=True, + client=client, + persisted_envelopes=(envelope,), + ) + ) + + assert status_code == cv.VERIFICATION_COMPLETED + assert results[0].claim_text == "Synthetic launch happened." + assert results[0].source_post_ids == ("public-post",) + + +def test_omitted_persisted_envelopes_fail_closed_without_token_overlap() -> None: + """A future caller cannot restore legacy question-token nomination.""" + client = _VerificationClient() + source = cv.GlobalAskSourceDocument( + "public-post", + "Synthetic launch", + "Synthetic launch happened.", + external_claim_facts=("event: Synthetic launch | evidence: public",), + ) + + status_code, results = asyncio.run( + global_ask_queue._verify_public_claims( + "When did the Synthetic launch happen?", + [source], + ["public-post"], + verify_external=True, + client=client, + ) + ) + + assert status_code == cv.VERIFICATION_NO_PUBLIC_CLAIMS + assert results == () + assert client.calls == 0 + + +def test_persisted_envelope_must_name_a_cited_post() -> None: + """A stored but uncited envelope never crosses the public verifier.""" + + client = _VerificationClient() + envelope = PersistedPublicClaimEnvelope( + public_claim_envelope_id="envelope-1", + source_post_id="other-public-post", + claim_kind_code="claim_public_relationship", + claim_text="Synthetic organizations announced a relationship.", + ) + + status_code, results = asyncio.run( + global_ask_queue._verify_public_claims( + "relationship", + [], + ["cited-public-post"], + verify_external=True, + client=client, + persisted_envelopes=(envelope,), + ) + ) + + assert status_code == cv.VERIFICATION_NO_PUBLIC_CLAIMS + assert results == () + assert client.calls == 0 + + def test_malformed_public_verification_is_unavailable() -> None: """Malformed provider/search envelopes do not discard a completed answer.""" source = cv.GlobalAskSourceDocument( @@ -189,6 +285,14 @@ def verify(self, _claim): ["public-post"], verify_external=True, client=MalformedClient(), + persisted_envelopes=( + PersistedPublicClaimEnvelope( + public_claim_envelope_id="envelope-1", + source_post_id="public-post", + claim_kind_code="claim_public_event", + claim_text="Synthetic public event.", + ), + ), ) ) diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 49072f5e6..781b430de 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -152,6 +152,156 @@ def test_post_json_posts_json_to_http_endpoint() -> None: assert _JsonHandler.received["authorization"] == "Bearer test-token" +@pytest.mark.parametrize("path", ["/v1/chat/completions", "/v1/responses"]) +def test_post_json_adds_explicit_routing_endpoint_to_supported_paths(path: str) -> None: + """An explicit opaque selector is scoped to the two orchestrator APIs.""" + server, base = _serve(_JsonHandler) + try: + body = post_json( + f"{base}{path}", + {"routing": {"region": "synthetic"}}, + headers={}, + timeout=2.0, + routing_endpoint="https://selected.example/v1", + ) + finally: + server.shutdown() + server.server_close() + + assert body["echo"]["routing"] == { + "region": "synthetic", + "endpoint": "https://selected.example/v1", + } + + +@pytest.mark.parametrize( + "path", ["/v1/embeddings", "/v1/batches", "/v1/chat/completions/"] +) +def test_post_json_does_not_route_other_paths(path: str) -> None: + """Embeddings, batches, and non-exact paths retain their original body.""" + server, base = _serve(_JsonHandler) + try: + body = post_json( + f"{base}{path}", + {"input": "synthetic"}, + headers={}, + timeout=2.0, + routing_endpoint="https://selected.example/v1", + ) + finally: + server.shutdown() + server.server_close() + + assert body["echo"] == {"input": "synthetic"} + + +def test_post_json_uses_deployment_routing_endpoint(monkeypatch) -> None: + """The runtime selector is the default when a call has no override.""" + monkeypatch.setenv( + "ORCHESTRATOR_ROUTING_ENDPOINT", "https://deployment.example/v1" + ) + server, base = _serve(_JsonHandler) + try: + body = post_json( + f"{base}/v1/responses", + {}, + headers={}, + timeout=2.0, + ) + finally: + server.shutdown() + server.server_close() + + assert body["echo"]["routing"] == { + "endpoint": "https://deployment.example/v1" + } + + +def test_post_json_blank_override_keeps_deployment_routing_endpoint(monkeypatch) -> None: + """A blank per-call value cannot silently disable deployment routing.""" + monkeypatch.setenv( + "ORCHESTRATOR_ROUTING_ENDPOINT", "https://deployment.example/v1" + ) + server, base = _serve(_JsonHandler) + try: + body = post_json( + f"{base}/v1/responses", + {}, + headers={}, + timeout=2.0, + routing_endpoint=" ", + ) + finally: + server.shutdown() + server.server_close() + + assert body["echo"]["routing"] == { + "endpoint": "https://deployment.example/v1" + } + + +def test_post_json_accepts_matching_existing_routing_endpoint() -> None: + """A caller-provided matching selector is preserved without conflict.""" + server, base = _serve(_JsonHandler) + try: + body = post_json( + f"{base}/v1/chat/completions", + {"routing": {"endpoint": "https://selected.example/v1"}}, + headers={}, + timeout=2.0, + routing_endpoint="https://selected.example/v1", + ) + finally: + server.shutdown() + server.server_close() + + assert body["echo"]["routing"] == { + "endpoint": "https://selected.example/v1" + } + + +def test_post_json_unset_routing_endpoint_preserves_payload(monkeypatch) -> None: + """An unset deployment selector preserves automatic routing behavior.""" + monkeypatch.delenv("ORCHESTRATOR_ROUTING_ENDPOINT", raising=False) + server, base = _serve(_JsonHandler) + try: + body = post_json( + f"{base}/v1/chat/completions", + {"messages": []}, + headers={}, + timeout=2.0, + ) + finally: + server.shutdown() + server.server_close() + + assert body["echo"] == {"messages": []} + + +@pytest.mark.parametrize( + "routing, message", + [ + ("invalid", "routing must be an object"), + ( + {"endpoint": "https://different.example/v1"}, + "routing.endpoint conflicts", + ), + ], +) +def test_post_json_rejects_invalid_or_conflicting_routing( + routing: object, message: str +) -> None: + """Malformed or conflicting caller routing fails before transport.""" + with pytest.raises(ValueError, match=message): + post_json( + "https://orchestrator.example/v1/chat/completions", + {"routing": routing}, + headers={}, + timeout=1.0, + routing_endpoint="https://selected.example/v1", + ) + + def test_get_json_fetches_json_from_http_endpoint() -> None: _JsonHandler.received = {} server, base = _serve(_JsonHandler) diff --git a/tests/test_http_client_edges.py b/tests/test_http_client_edges.py index d22ae9aba..f6712aba4 100644 --- a/tests/test_http_client_edges.py +++ b/tests/test_http_client_edges.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import pytest from lineageweave import http_client @@ -187,16 +189,27 @@ def capture_request(*_args: object, **kwargs: object) -> tuple[int, bytes]: assert b'"lineageweave_post_id": "synthetic-post"' in captured_body +@pytest.mark.parametrize( + ("status", "error_code"), + [(429, "rate_limit_exceeded"), (503, "no_viable_agent")], +) def test_post_json_exposes_only_validated_admission_deferral( monkeypatch: pytest.MonkeyPatch, + status: int, + error_code: str, ) -> None: """The exact bounded retry contract becomes a typed control signal.""" def deferred_request(*_args: object, **kwargs: object) -> tuple[int, bytes]: kwargs["response_control_headers"]["retry-after"] = "30" return ( - 503, - b'{"error":{"code":"no_viable_agent","detail":{"retry_after_seconds":30}}}', + status, + json.dumps({ + "error": { + "code": error_code, + "detail": {"retry_after_seconds": 30}, + } + }).encode("utf-8"), ) monkeypatch.setattr(http_client, "_request", deferred_request) @@ -209,23 +222,76 @@ def deferred_request(*_args: object, **kwargs: object) -> tuple[int, bytes]: ) assert captured.value.retry_after_seconds == 30 - assert "no_viable_agent" not in str(captured.value) + assert error_code not in str(captured.value) +@pytest.mark.parametrize( + ("status", "error_code"), + [(429, "rate_limit_exceeded"), (503, "no_viable_agent")], +) def test_post_json_rejects_mismatched_admission_delay( monkeypatch: pytest.MonkeyPatch, + status: int, + error_code: str, ) -> None: """Conflicting header/body delays remain an ordinary unavailable response.""" def mismatched_request(*_args: object, **kwargs: object) -> tuple[int, bytes]: kwargs["response_control_headers"]["retry-after"] = "31" return ( - 503, - b'{"error":{"code":"no_viable_agent","detail":{"retry_after_seconds":30}}}', + status, + json.dumps({ + "error": { + "code": error_code, + "detail": {"retry_after_seconds": 30}, + } + }).encode("utf-8"), ) monkeypatch.setattr(http_client, "_request", mismatched_request) - with pytest.raises(http_client.HttpClientError, match="HTTP 503") as captured: + with pytest.raises(http_client.HttpClientError, match=f"HTTP {status}") as captured: + http_client.post_json( + "https://gateway.example/v1/chat/completions", + {}, + headers={}, + timeout=1, + ) + + assert not isinstance(captured.value, http_client.HttpAdmissionDeferred) + + +@pytest.mark.parametrize( + ("status", "error_code"), + [(429, "rate_limit_exceeded"), (503, "no_viable_agent")], +) +@pytest.mark.parametrize( + ("retry_after", "detail_seconds"), + [(None, 30), ("30", None), ("0", 0), ("30", True), ("+30", 30)], +) +def test_post_json_rejects_missing_or_malformed_admission_delay( + monkeypatch: pytest.MonkeyPatch, + status: int, + error_code: str, + retry_after: str | None, + detail_seconds: object, +) -> None: + """Incomplete or non-canonical admission controls fail closed.""" + + def malformed_request(*_args: object, **kwargs: object) -> tuple[int, bytes]: + if retry_after is not None: + kwargs["response_control_headers"]["retry-after"] = retry_after + return ( + status, + json.dumps({ + "error": { + "code": error_code, + "detail": {"retry_after_seconds": detail_seconds}, + } + }).encode("utf-8"), + ) + + monkeypatch.setattr(http_client, "_request", malformed_request) + with pytest.raises(http_client.HttpClientError, match=f"HTTP {status}") as captured: http_client.post_json( "https://gateway.example/v1/chat/completions", {}, diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 733189357..fe39f5dfa 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -184,6 +184,27 @@ def test_global_ask_migrations_are_safe_to_replay() -> None: assert "create table if not exists global_ask_job_process_unit_scope" in scope_sql +def test_public_claim_envelope_migration_is_replay_safe_and_provenance_bound() -> None: + """Persisted public egress admission requires the exact PROV-O source post.""" + + sql = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0257_public_claim_envelope.sql" + ).read_text(encoding="utf-8").casefold() + + assert "create table if not exists public_claim_envelope" in sql + assert "provenance_assertion_id uuid not null" in sql + assert "prov_was_derived_from" in sql + assert "evidence_post_id is distinct from new.source_post_id" in sql + assert "when count(binding.node_id) = 1" in sql + assert "then (array_agg(binding.node_id))[1]" in sql + assert "min(binding.node_id)" not in sql + assert "group by assertion.relation_code" in sql + assert "public_claim_requires_public_post" in sql + assert "on conflict (lookup_code) do nothing" in sql + + def test_channel_weight_migration_preserves_raw_source_grouping() -> None: migration = ( Path(__file__).resolve().parents[1] @@ -260,6 +281,56 @@ def test_topic_lineage_result_migration_is_idempotent_for_replay() -> None: assert "create index if not exists" in migration +def test_topic_influence_job_migration_is_replay_safe_and_fail_closed() -> None: + """Existing TEPP projections gain one durable, score-free producer lease.""" + sql = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0260_topic_influence_job.sql" + ).read_text(encoding="utf-8").casefold() + + assert "create table if not exists topic_influence_job" in sql + assert "not_before" in sql + assert "lease_expires_at" in sql + assert "awaiting_evidence" in sql + assert "wake_topic_influence_job_for_analysis" in sql + assert "topic_model_run_influence_wake" in sql + assert "analysis_run_influence_wake" in sql + assert "drop trigger if exists topic_tepp_receipt_influence_wake" in sql + assert "create trigger topic_tepp_receipt_influence_wake" not in sql + assert "drop trigger if exists topic_terminal_influence_wake" in sql + assert "create trigger topic_terminal_influence_wake" not in sql + assert "after insert or update on topic_post_coordinate" in sql + assert "after insert or update on topic_context_membership" in sql + assert "after insert or update on topic_definition" in sql + assert "create trigger topic_provenance_binding_influence_wake" in sql + assert "after insert or update on provenance_resource_binding" in sql + assert "new.node_type_code = 'node_post'" in sql + assert "old.node_type_code = 'node_post'" in sql + assert "assertion.relation_code = 'prov_was_derived_from'" in sql + assert "membership.source_post_id = new.node_id" in sql + assert "membership.source_post_id = old.node_id" in sql + assert "create trigger topic_provenance_assertion_influence_wake" in sql + assert ( + "after update of object_resource_id, relation_code on provenance_assertion" + in sql + ) + assert "membership.provenance_assertion_id = new.assertion_id" in sql + assert "add column if not exists lease_expires_at" in sql + assert "drop constraint if exists topic_influence_job_check" in sql + assert "and (lease_expires_at is null or lease_token is null)" in sql + assert "add column if not exists lease_token uuid" in sql + prelease_recovery = sql.split("update topic_influence_job", 1)[1].split( + "alter table topic_influence_job", 1 + )[0] + assert "lease_expires_at = null" in prelease_recovery + assert "lease_token = null" in prelease_recovery + assert "create trigger topic_model_run_influence_queue" in sql + assert "on conflict (topic_model_run_id) do nothing" in sql + assert "where status_code = 'queued'" in sql + assert "influence_value" not in sql + + def test_tepp_receipt_migration_is_replayable_and_digest_bound() -> None: """Accepted transport evidence survives every-start migration replay.""" migration_name = "0217_analysis_run_tepp_receipt.sql" diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 7fa3383f6..c531c7b70 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -90,7 +90,6 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "project_name": "Synthetic Project", "project_names": ["Synthetic Project", "Synthetic Secondary Project"], "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), - "event_count": 2, } ] @@ -315,6 +314,13 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: in case_query ) assert "observed_at nulls last" in conn.queries[5][0] + metrics_query = conn.queries[0][0] + assert "post_summary_event" not in metrics_query + assert "post_summary_event" not in case_query + milestone_query = conn.queries[5][0] + assert "join source_post evidence_post" in milestone_query + assert "evidence_post.post_id = milestone.evidence_post_id" in milestone_query + assert "evidence_post.visibility_code = 'public'" in milestone_query for evidence_query in ( conn.queries[0][0], conn.queries[1][0], @@ -327,6 +333,61 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: assert "($6::jsonb -> fact.case_kind_code) ? fact.fact_type_code" in missing_query +@pytest.mark.anyio +async def test_dashboard_counts_each_case_milestone_set_once() -> None: + """Multiple classification evidence rows cannot duplicate one case's events.""" + + class DuplicateClassificationConnection(_Connection): + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + rows = await super().fetch(query, *args) + if "operations_case_classification classification" in query: + return [rows[0], {**rows[0], "evidence_post_id": "00000000-0000-0000-0000-000000000003"}] + return rows + + result = await fetch_operations_dashboard(DuplicateClassificationConnection(), []) + + assert result["total_event_count"] == 2 + assert result["case_metrics"][0]["event_count"] == 2 + + +@pytest.mark.anyio +async def test_dashboard_headline_excludes_hidden_milestone_evidence() -> None: + """Headline and per-type counts share the evidence-visible milestone rows.""" + + class HiddenMilestoneConnection(_Connection): + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + if "operations_case_milestone milestone" in query: + self.queries.append((query, args)) + return [] + return await super().fetch(query, *args) + + result = await fetch_operations_dashboard(HiddenMilestoneConnection(), []) + + assert result["total_event_count"] == 0 + assert sum(metric["event_count"] for metric in result["case_metrics"]) == 0 + + +@pytest.mark.anyio +async def test_dashboard_event_counts_exclude_hidden_classification_evidence() -> None: + """A milestone cannot outlive the visible classification that owns it.""" + + class HiddenClassificationConnection(_Connection): + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + if ( + "from operations_case_classification classification" in query + and "operations_case_fact" not in query + ): + self.queries.append((query, args)) + return [] + return await super().fetch(query, *args) + + result = await fetch_operations_dashboard(HiddenClassificationConnection(), []) + + assert result["cases"] == [] + assert result["total_event_count"] == 0 + assert sum(metric["event_count"] for metric in result["case_metrics"]) == 0 + + @pytest.mark.anyio async def test_dashboard_projects_exact_topic_influence_without_local_scoring() -> None: """Accepted rows retain ties, membership evidence, and producer identity.""" @@ -576,7 +637,6 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "project_name": "Synthetic Project", "project_names": ["Synthetic Project"], "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), - "event_count": 1, }] result = await fetch_operations_dashboard(ExternalConnection(), []) diff --git a/tests/test_orchestrator_compose_embedding_contract.py b/tests/test_orchestrator_compose_embedding_contract.py index bce83b7b3..425677428 100644 --- a/tests/test_orchestrator_compose_embedding_contract.py +++ b/tests/test_orchestrator_compose_embedding_contract.py @@ -21,7 +21,16 @@ def test_rendered_compose_keeps_embedding_selection_upstream(tmp_path: Path) -> standalone_compose = shutil.which("docker-compose") compose_command = [standalone_compose] if standalone_compose else ["docker", "compose"] rendered = subprocess.run( - [*compose_command, "-f", str(_ROOT / "docker-compose.yml"), "config", "--format", "json"], + [ + *compose_command, + "-f", + str(_ROOT / "docker-compose.yml"), + "--profile", + "mcp", + "config", + "--format", + "json", + ], cwd=_ROOT, env=environment, check=True, @@ -53,6 +62,37 @@ def test_rendered_compose_keeps_embedding_selection_upstream(tmp_path: Path) -> "/bin/sh", "/app/backend/worker-healthcheck.sh", ] + assert backend_environment["ORCHESTRATOR_ROUTING_ENDPOINT"] == "" + assert config["services"]["backend-worker"]["environment"][ + "ORCHESTRATOR_ROUTING_ENDPOINT" + ] == "" + assert config["services"]["mcp"]["environment"][ + "ORCHESTRATOR_ROUTING_ENDPOINT" + ] == "" + assert "env_file" not in config["services"]["backend"] + assert "env_file" not in config["services"]["backend-worker"] + assert "env_file" not in config["services"]["mcp"] + + +def test_routing_endpoint_contract_is_documented() -> None: + """The ADR limits the runtime selector to exact text API paths.""" + adr = ( + _ROOT / "docs/adr/0070-contextual-orchestrator-upstream-integration.md" + ).read_text(encoding="utf-8") + assert "`ORCHESTRATOR_ROUTING_ENDPOINT`" in adr + assert "exactly `/v1/chat/completions` or `/v1/responses`" in adr + assert "not applied to embeddings, batch routes" in adr + + compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + selector_boundary = ( + "ORCHESTRATOR_ROUTING_ENDPOINT: ${ORCHESTRATOR_ROUTING_ENDPOINT:-}" + ) + assert compose.count(selector_boundary) == 2 + orchestrator_service = compose.split(" orchestrator:\n", 1)[1].split( + " backend:\n", 1 + )[0] + assert "env_file:\n - ${HOME}/.env" in orchestrator_service + assert selector_boundary not in orchestrator_service def test_lineage_clients_do_not_select_an_embedding_model() -> None: @@ -60,6 +100,10 @@ def test_lineage_clients_do_not_select_an_embedding_model() -> None: compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") assert "LLM_GATEWAY_EMBEDDING_MODEL:" not in compose assert "LLM_GATEWAY_EMBEDDING_PROVIDER:" not in compose + start = (_ROOT / "docker/contextual-orchestrator/start.py").read_text( + encoding="utf-8" + ) + assert 'os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)' in start def test_orchestrator_image_tag_matches_the_downloaded_revision() -> None: @@ -95,6 +139,13 @@ def test_orchestrator_image_verifies_archive_and_dependency_bytes() -> None: ) assert "--require-hashes" in dockerfile assert "-r /tmp/orchestrator-requirements.lock" in dockerfile + assert re.search( + r"ARG MATURIN_BUILDER_IMAGE=ghcr\.io/pyo3/maturin@sha256:[0-9a-f]{64}", + dockerfile, + ) + assert "maturin build --locked --release" in dockerfile + assert "COPY --from=token-builder /build/wheels /tmp/token-wheels" in dockerfile + assert "python -m pip install --no-cache-dir --no-deps \"$1\"" in dockerfile assert not re.search(r"(?:>=|~=|==[^\n ]*\*)", roots) assert not re.search( r"^[a-z0-9_.-]+(?:\[[^]]+\])?\s*(?:>=|~=|==[^\n ]*\*)", @@ -104,5 +155,16 @@ def test_orchestrator_image_verifies_archive_and_dependency_bytes() -> None: locked_packages = re.findall( r"^([a-z0-9_.-]+)==[^\\\n ]+ \\$", requirements, re.MULTILINE ) - assert len(locked_packages) == len(set(locked_packages)) == 14 + assert len(locked_packages) == len(set(locked_packages)) + assert len(locked_packages) >= 14 assert requirements.count("--hash=sha256:") >= len(locked_packages) + + +def test_orchestrator_build_verifier_executes_the_native_token_packer() -> None: + """A source-only image must fail before runtime when the Rust wheel is absent.""" + verifier = ( + _ROOT / "docker/contextual-orchestrator/verify_startup_contract.py" + ).read_text(encoding="utf-8") + assert "from contextual_orchestrator.token_counting import RustCl100kPacker" in verifier + assert "token_packer = RustCl100kPacker()" in verifier + assert 'token_packer.count_text("hello") == 1' in verifier diff --git a/tests/test_post_content_backfill_schema.py b/tests/test_post_content_backfill_schema.py new file mode 100644 index 000000000..f24251b81 --- /dev/null +++ b/tests/test_post_content_backfill_schema.py @@ -0,0 +1,28 @@ +"""Static schema contract for the bounded post-content backfill scan.""" + +from pathlib import Path + + +MIGRATION = Path("migrations/0258_post_content_backfill_candidate_index.sql") + + +def test_backfill_candidate_index_matches_the_ordered_eligibility_scan() -> None: + """The replay-safe partial index owns ordering and source eligibility.""" + sql = " ".join(MIGRATION.read_text().lower().split()) + + assert "create index if not exists source_post_content_backfill_candidate_idx" in sql + assert ( + "on source_post ( coalesce(event_occurred_at, created_at), created_at, post_id )" + in sql + ) + for column in ("source_draft_code", "source_deleted_flag"): + assert f"nullif(btrim({column}), '')" in sql + + +def test_backfill_index_owns_its_stacked_migration_identity() -> None: + """The index owns 0258 rather than reusing the parent stack's 0257.""" + + migrations = MIGRATION.parent + + assert MIGRATION.name.startswith("0258_") + assert not (migrations / "0257_post_content_backfill_candidate_index.sql").exists() diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py index 0fa79949a..e66b1205c 100644 --- a/tests/test_post_content_queue.py +++ b/tests/test_post_content_queue.py @@ -4,7 +4,7 @@ import asyncio import re -from datetime import timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -44,6 +44,43 @@ def test_stream_is_a_wakeup_and_never_contains_a_body() -> None: assert source_body_sha256("body") != source_body_sha256("changed") +def test_worker_outage_keeps_the_wakeup_transport_bounded() -> None: + """Producer traffic cannot grow the non-authoritative stream without limit.""" + from backend.app.post_content_queue import publish_post_content_event + + class Client: + def __init__(self) -> None: + self.entries: list[dict[str, str]] = [] + + async def xadd( + self, + _stream: str, + fields: dict[str, str], + *, + maxlen: int, + approximate: bool, + ) -> str: + assert maxlen == 1000 + assert approximate is True + self.entries.append(fields) + self.entries = self.entries[-maxlen:] + return f"1-{len(self.entries)}" + + client = Client() + + async def publish_corpus() -> None: + for index in range(1005): + await publish_post_content_event( + client, + post_id=f"00000000-0000-0000-0000-{index:012d}", + source_body_digest="a" * 64, + ) + + asyncio.run(publish_corpus()) + assert len(client.entries) == 1000 + assert client.entries[0]["post_id"].endswith("000000000005") + + def test_bounded_backfill_is_idempotent_and_broker_loss_stays_recoverable( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -65,11 +102,11 @@ def transaction(self) -> Transaction: async def fetch(self, query: str, *args: object) -> list[dict[str, str]]: assert "source_draft_code" in query assert "source_deleted_flag" in query - assert "job.post_id is null or job.status_code = $1" in query - assert "left join operations_case_analysis analysis" in query - assert "analysis.post_id = post.post_id" in query + assert "job.status_code is distinct from $1" in query + assert "join operations_case_analysis analysis" in query + assert "analysis.post_id = job.post_id" in query assert "analysis.source_body_sha256 = job.source_body_sha256" in query - assert "left join post_product_analysis product_analysis" in query + assert "join post_product_analysis product_analysis" in query assert "from post_project_mention project" in query assert "nullif(btrim(project.ontology_iri), '') is not null" in query assert "job.source_body_sha256 is not null" in query @@ -410,7 +447,8 @@ class FakeConnection: async def fetch(self, query: str, *args: object): assert "status_code = $1" in query assert "status_code = $3" in query - assert "started_at < now() - $4::interval" in query + assert "started_at + $4::interval" in query + assert "eligible_at <= now()" in query assert args[0] == QUEUED assert args[2] == RUNNING assert args[1] == POST_CONTENT_RETRY_INTERVAL @@ -418,6 +456,7 @@ async def fetch(self, query: str, *args: object): { "post_id": "00000000-0000-0000-0000-000000000001", "source_body_sha256": "a" * 64, + "eligible_at": "2026-01-01T00:00:00Z", } ] @@ -444,9 +483,10 @@ async def publish(_client, *, post_id: str, source_body_digest: str) -> bool: original = post_content_queue.publish_post_content_event post_content_queue.publish_post_content_event = publish try: - assert asyncio.run( + page = asyncio.run( post_content_queue.republish_queued_post_content_jobs(Client(), Pool()) - ) == 1 + ) + assert page.published_count == 1 finally: post_content_queue.publish_post_content_event = original assert published == [("00000000-0000-0000-0000-000000000001", "a" * 64)] @@ -731,7 +771,7 @@ async def execute(self, query: str, *args: object) -> str: assert executed[1][1][-1] == "operator backfill persisted post-content evidence" -def test_recovery_republishes_due_rows_in_queued_at_order() -> None: +def test_recovery_republishes_due_rows_in_effective_eligibility_order() -> None: from contextlib import asynccontextmanager from backend.app.post_content_queue import republish_queued_post_content_jobs @@ -745,8 +785,16 @@ async def fetch(self, query: str, *args: object): self.query = query self.args = args return [ - {"post_id": "first", "source_body_sha256": "a" * 64}, - {"post_id": "second", "source_body_sha256": "b" * 64}, + { + "post_id": "first", + "source_body_sha256": "a" * 64, + "eligible_at": "2026-01-01T00:00:00Z", + }, + { + "post_id": "second", + "source_body_sha256": "b" * 64, + "eligible_at": "2026-01-01T00:00:01Z", + }, ] class FakePool: @@ -767,16 +815,236 @@ async def xadd(self, _stream: str, fields: dict[str, str], **_kwargs: object) -> connection = FakeConnection() client = FakeClient() - published = asyncio.run( + page = asyncio.run( republish_queued_post_content_jobs(client, FakePool(connection), limit=2) ) - assert published == 2 + assert page.published_count == 2 + assert page.next_post_id == "second" assert client.events == [("first", "a" * 64), ("second", "b" * 64)] - assert "next_attempt_at <= now()" in connection.query - assert "queued_at <= now() - $2::interval" in connection.query - assert "order by queued_at" in connection.query - assert connection.args == (QUEUED, POST_CONTENT_RETRY_INTERVAL, RUNNING, STALE_RUNNING_INTERVAL, 2) + assert "when next_attempt_at is not null then next_attempt_at" in connection.query + assert "when attempt_count = 0 then queued_at" in connection.query + assert "else queued_at + $2::interval" in connection.query + assert "started_at + $4::interval" in connection.query + assert "where eligible_at <= now()" in connection.query + assert "order by eligible_at, post_id" in connection.query + assert connection.args == ( + QUEUED, + POST_CONTENT_RETRY_INTERVAL, + RUNNING, + STALE_RUNNING_INTERVAL, + None, + None, + 2, + ) + + +def test_recovery_keyset_reaches_later_pages_and_wraps() -> None: + """Repeated recovery reaches every ready row instead of replaying page one.""" + from contextlib import asynccontextmanager + + from backend.app.post_content_queue import republish_queued_post_content_jobs + + queued_at = [ + datetime(2026, 1, 1, 0, 0, index, tzinfo=UTC) for index in range(3) + ] + rows = [ + { + "post_id": f"00000000-0000-0000-0000-{index + 1:012d}", + "source_body_sha256": str(index + 1) * 64, + "eligible_at": queued_at[index], + } + for index in range(3) + ] + + class FakeConnection: + async def fetch(self, _query: str, *args: object): + cursor_at, cursor_id, limit = args[-3:] + if cursor_at is None: + return rows[: int(limit)] + return [ + row + for row in rows + if (row["eligible_at"], row["post_id"]) > (cursor_at, cursor_id) + ][: int(limit)] + + class FakePool: + @asynccontextmanager + async def acquire(self): + yield FakeConnection() + + class FakeClient: + def __init__(self) -> None: + self.published: list[str] = [] + + async def xadd( + self, + _stream: str, + fields: dict[str, str], + *, + maxlen: int, + approximate: bool, + ) -> str: + assert maxlen == 1000 + assert approximate is True + self.published.append(fields["post_id"]) + return str(len(self.published)) + + client = FakeClient() + first = asyncio.run( + republish_queued_post_content_jobs(client, FakePool(), limit=2) + ) + second = asyncio.run( + republish_queued_post_content_jobs( + client, + FakePool(), + limit=2, + after_eligible_at=first.next_eligible_at, + after_post_id=first.next_post_id, + ) + ) + wrapped = asyncio.run( + republish_queued_post_content_jobs( + client, + FakePool(), + limit=2, + after_eligible_at=second.next_eligible_at, + after_post_id=second.next_post_id, + ) + ) + + assert client.published == [ + rows[0]["post_id"], + rows[1]["post_id"], + rows[2]["post_id"], + rows[0]["post_id"], + rows[1]["post_id"], + ] + assert wrapped.next_post_id == rows[1]["post_id"] + + +def test_recovery_reaches_retry_when_it_becomes_due_after_cursor_advanced() -> None: + """A newly due retry remains ahead by its exact eligibility instant.""" + from contextlib import asynccontextmanager + + from backend.app.post_content_queue import republish_queued_post_content_jobs + + initial_at = datetime(2026, 1, 1, tzinfo=UTC) + retry_eligible_at = initial_at + timedelta(minutes=5) + rows = [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "source_body_sha256": "a" * 64, + "eligible_at": initial_at, + } + ] + + class FakeConnection: + async def fetch(self, _query: str, *args: object): + cursor_at, cursor_id, limit = args[-3:] + return [ + row + for row in rows + if cursor_at is None + or (row["eligible_at"], row["post_id"]) > (cursor_at, cursor_id) + ][: int(limit)] + + class FakePool: + @asynccontextmanager + async def acquire(self): + yield FakeConnection() + + class FakeClient: + async def xadd(self, *_args: object, **_kwargs: object) -> str: + return "1-0" + + client = FakeClient() + first = asyncio.run( + republish_queued_post_content_jobs(client, FakePool(), limit=1) + ) + rows.append( + { + "post_id": "00000000-0000-0000-0000-000000000002", + "source_body_sha256": "b" * 64, + "eligible_at": retry_eligible_at, + } + ) + second = asyncio.run( + republish_queued_post_content_jobs( + client, + FakePool(), + limit=1, + after_eligible_at=first.next_eligible_at, + after_post_id=first.next_post_id, + ) + ) + + assert second.next_eligible_at == retry_eligible_at + assert second.next_post_id == rows[1]["post_id"] + + +def test_recovery_cursor_stops_before_a_failed_wakeup() -> None: + """A broker outage retries the first unpublished row before later pages.""" + from contextlib import asynccontextmanager + + from backend.app.post_content_queue import republish_queued_post_content_jobs + + queued_at = datetime(2026, 1, 1, tzinfo=UTC) + rows = [ + { + "post_id": f"00000000-0000-0000-0000-{index + 1:012d}", + "source_body_sha256": str(index + 1) * 64, + "eligible_at": queued_at + timedelta(seconds=index), + } + for index in range(2) + ] + + class FakeConnection: + async def fetch(self, _query: str, *args: object): + cursor_at, cursor_id = args[-3:-1] + if cursor_at is None: + return rows + return [ + row + for row in rows + if (row["eligible_at"], row["post_id"]) > (cursor_at, cursor_id) + ] + + class FakePool: + @asynccontextmanager + async def acquire(self): + yield FakeConnection() + + class FakeClient: + def __init__(self) -> None: + self.calls = 0 + + async def xadd(self, *_args, **_kwargs): + self.calls += 1 + if self.calls == 2: + raise post_content_queue.redis.RedisError("synthetic broker outage") + return str(self.calls) + + from backend.app import post_content_queue + + client = FakeClient() + first = asyncio.run( + republish_queued_post_content_jobs(client, FakePool(), limit=2) + ) + assert first.published_count == 1 + assert first.next_post_id == rows[0]["post_id"] + + second = asyncio.run( + republish_queued_post_content_jobs( + client, + FakePool(), + limit=2, + after_eligible_at=first.next_eligible_at, + after_post_id=first.next_post_id, + ) + ) + assert second.published_count == 1 + assert second.next_post_id == rows[1]["post_id"] def test_admission_deferral_requeues_exact_lease_without_consuming_attempt() -> None: diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index 93c2bb300..aea02641f 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -663,6 +663,7 @@ def test_invalid_product_output_keeps_the_job_retryable(monkeypatch) -> None: outcomes: list[str] = [] persisted: list[str] = [] failures: list[tuple[str, str]] = [] + failed_stages: list[str | None] = [] channel_order: list[str] = [] async def claim(*_args, **_kwargs): @@ -682,6 +683,9 @@ async def persist_content(*_args, **_kwargs): async def finish(_pool, _post_id, status, **_kwargs): outcomes.append(status) + async def finish_failed(_pool, _post_id, **kwargs): + failed_stages.append(kwargs.get("channel_stage_code")) + monkeypatch.setattr(post_content_worker, "_claim_job", claim) monkeypatch.setattr( post_content_worker, @@ -727,6 +731,7 @@ async def finish(_pool, _post_id, status, **_kwargs): post_content_worker, "_requeue_project_missing_case_jobs", lambda *_args: asyncio.sleep(0) ) monkeypatch.setattr(post_content_worker, "_finish_job", finish) + monkeypatch.setattr(post_content_worker, "_finish_failed_job", finish_failed) monkeypatch.setattr( post_content_worker, "record_server_failure", @@ -748,12 +753,77 @@ async def finish(_pool, _post_id, status, **_kwargs): assert persisted == ["cases"] assert channel_order == ["cases", "product"] assert outcomes == [] + assert failed_stages == ["product_analysis"] assert failures == [ ("product_semantic_ingestion", "provider_unavailable"), ("post_content_ingestion", "internal_error"), ] +def test_occupational_construct_failure_keeps_its_own_stage(monkeypatch) -> None: + """Construct extraction failures are not mislabeled as product failures.""" + failed_stages: list[str | None] = [] + + async def claim(*_args, **_kwargs): + return _row(RUNNING, 1) + + async def fail_construct(*_args, **_kwargs): + raise ValueError("synthetic construct response") + + async def finish_failed(_pool, _post_id, **kwargs): + failed_stages.append(kwargs.get("channel_stage_code")) + + monkeypatch.setattr(post_content_worker, "_claim_job", claim) + monkeypatch.setattr( + post_content_worker, + "load_settings", + lambda: SimpleNamespace( + orchestrator_base_url="gateway", orchestrator_api_key="key" + ), + ) + monkeypatch.setattr( + post_content_worker, + "_operations_evidence_sources", + lambda *_args, **_kwargs: asyncio.sleep( + 0, + result=(OperationsEvidenceSource("post-1", "Synthetic", "Evidence"),), + ), + ) + monkeypatch.setattr( + post_content_worker, + "_persist_operations_case_analysis_if_needed", + lambda *_args, **_kwargs: asyncio.sleep(0), + ) + monkeypatch.setattr( + post_content_worker, + "_persist_product_analysis_if_needed", + lambda *_args, **_kwargs: asyncio.sleep(0), + ) + monkeypatch.setattr( + post_content_worker, "extract_occupational_construct_assertions", fail_construct + ) + monkeypatch.setattr(post_content_worker, "_finish_failed_job", finish_failed) + monkeypatch.setattr( + post_content_worker, + "record_server_failure", + lambda *_args, **_kwargs: None, + ) + client = SimpleNamespace(available=True, resolved_model="synthetic-model") + + asyncio.run( + post_content_worker.process_post_content_job( + _Pool(_Connection()), + post_id="00000000-0000-0000-0000-000000000001", + source_body_digest="a" * 64, + vision_factory=lambda: client, + embedding_factory=lambda: client, + structure_factory=lambda: client, + ) + ) + + assert failed_stages == ["occupational_construct"] + + def test_case_analysis_persists_before_content_provider_failure(monkeypatch) -> None: """Independent case evidence survives a later structure or embedding outage.""" connection = _Connection(values=[False, 2]) @@ -1128,8 +1198,12 @@ async def enqueue(actual_pool: object, actual_client: object, **kwargs: object) "require_structure": True, } - async def republish(actual_client: object, actual_pool: object) -> None: + async def republish( + actual_client: object, actual_pool: object, **kwargs: object + ) -> object: calls.append(("republish", actual_pool, actual_client)) + assert kwargs == {"after_eligible_at": None, "after_post_id": None} + return SimpleNamespace(next_eligible_at=None, next_post_id=None) monkeypatch.setattr( post_content_worker, diff --git a/tests/test_project_history.py b/tests/test_project_history.py index 8eedec3fe..8b716fd1d 100644 --- a/tests/test_project_history.py +++ b/tests/test_project_history.py @@ -57,7 +57,14 @@ def test_prior_paths_keep_the_first_deterministic_shortest_route_per_predecessor paths = _prior_paths( ["award", "spec-a", "spec-b", "delivery"], [ - {"parent_post_id": "award", "child_post_id": "spec-a", "fused_score": 0.9}, + { + "parent_post_id": "award", + "child_post_id": "spec-a", + "fused_score": 0.9, + "temporal_observed": True, + "allen_relations": ["before"], + "artifact_digest_sha256": "a" * 64, + }, {"parent_post_id": "award", "child_post_id": "spec-b", "fused_score": 0.8}, {"parent_post_id": "spec-a", "child_post_id": "delivery", "fused_score": 0.7}, {"parent_post_id": "spec-b", "child_post_id": "delivery", "fused_score": 0.6}, @@ -68,3 +75,9 @@ def test_prior_paths_keep_the_first_deterministic_shortest_route_per_predecessor award_paths = [path for path in paths["delivery"] if path["source_event_id"] == "award"] assert [path["event_ids"] for path in award_paths] == [["award", "spec-a", "delivery"]] + assert award_paths[0]["edges"][0]["temporal_evidence"] == { + "truth_status_code": "observed", + "interval_relations": ["before"], + "artifact_digest_sha256": "a" * 64, + } + assert award_paths[0]["edges"][1]["temporal_evidence"] is None diff --git a/tests/test_project_history_ingestion.py b/tests/test_project_history_ingestion.py index c4886becf..c07a595b8 100644 --- a/tests/test_project_history_ingestion.py +++ b/tests/test_project_history_ingestion.py @@ -57,6 +57,13 @@ def test_project_history_query_binds_corporate_and_process_scopes() -> None: assert result["events"][0]["event_type_code"] == "source_recorded" assert result["events"][0]["occurred_at"] == "2025-12-20T00:00:00Z" assert result["events"][0]["time_basis_code"] == "document_time" + edge_query, edge_args = next( + (query, args) for query, args in connection.calls if "from post_lineage_edge" in query + ) + assert "project_journey_temporal_relation" in edge_query + assert "project_journey_temporal_relation_kind" in edge_query + assert "temporal_run.knowledge_cutoff <= $2" in edge_query + assert edge_args[1] == datetime(2026, 2, 1, tzinfo=timezone.utc) def test_project_history_query_uses_the_same_ascii_edge_whitespace_as_python() -> None: diff --git a/tests/test_public_claim_envelope.py b/tests/test_public_claim_envelope.py new file mode 100644 index 000000000..f0cbb9579 --- /dev/null +++ b/tests/test_public_claim_envelope.py @@ -0,0 +1,36 @@ +"""Persisted public-claim admission boundary regressions.""" + +from lineageweave.claim_verification import PublicClaimCandidate +from lineageweave.public_claim_envelope import envelope_from_authorized_row + + +def _row(**overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "public_claim_envelope_id": "00000000-0000-0000-0000-000000000101", + "source_post_id": "00000000-0000-0000-0000-000000000201", + "claim_kind_code": "claim_public_event", + "claim_text": "Synthetic project reached its published milestone.", + } + row.update(overrides) + return row + + +def test_persisted_envelope_projects_exact_claim_and_provenance() -> None: + """Admission preserves the stored claim and its one evidence post.""" + + envelope = envelope_from_authorized_row(_row()) + + assert envelope is not None + assert envelope.verification_candidate() == PublicClaimCandidate( + claim_text="Synthetic project reached its published milestone.", + claim_kind="claim_public_event", + source_post_ids=("00000000-0000-0000-0000-000000000201",), + ) + + +def test_persisted_envelope_rejects_unregistered_or_malformed_claims() -> None: + """Person-like and malformed rows cannot be repaired into egress claims.""" + + assert envelope_from_authorized_row(_row(claim_kind_code="person")) is None + assert envelope_from_authorized_row(_row(claim_text="")) is None + assert envelope_from_authorized_row(_row(claim_text="x" * 801)) is None diff --git a/tests/test_runtime_image_revision_contract.py b/tests/test_runtime_image_revision_contract.py index 12fad4d6f..a304277eb 100644 --- a/tests/test_runtime_image_revision_contract.py +++ b/tests/test_runtime_image_revision_contract.py @@ -7,7 +7,7 @@ def test_product_images_expose_explicit_source_revision() -> None: - """Backend and frontend images must label their operator-supplied revision.""" + """Every product image must label its operator-supplied source revision.""" for path in (_ROOT / "backend" / "Dockerfile", _ROOT / "frontend" / "Dockerfile"): dockerfile = path.read_text(encoding="utf-8") assert "ARG LINEAGEWEAVE_SOURCE_REVISION=unknown" in dockerfile @@ -19,13 +19,38 @@ def test_product_images_expose_explicit_source_revision() -> None: assert "io.contextualwisdomlab.lineageweave.oidc-issuer" in frontend assert "io.contextualwisdomlab.lineageweave.backend-url" in frontend + orchestrator = ( + _ROOT / "docker" / "contextual-orchestrator" / "Dockerfile" + ).read_text(encoding="utf-8") + assert "ARG CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION=unknown" in orchestrator + assert ( + "LABEL org.opencontainers.image.revision=" + "${CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION}" + ) in orchestrator + def test_compose_passes_revision_to_all_product_images() -> None: """Compose must pass the same fail-closed revision input to each product build.""" compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") assert compose.count( "LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}" - ) == 3 + ) == 4 + assert ( + "CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION: " + "3558a9a3aeb985282b255fcd80bb2201c19ae54b" + ) in compose + + +def test_runtime_acceptance_checks_every_product_image_revision() -> None: + """Acceptance must reject any stale backend, worker, MCP, or frontend image.""" + runner = (_ROOT / "scripts" / "accept_operations_dashboard_runtime.sh").read_text( + encoding="utf-8" + ) + assert "for service_name in backend backend-worker mcp frontend; do" in runner + assert "lineageweave-${service_name}-1" in runner + assert '[[ ",${COMPOSE_PROFILES:-}," == *,mcp,* ]]' in runner + assert "docker inspect lineageweave-mcp-1 >/dev/null 2>&1" in runner + assert "start the accepted stack with COMPOSE_PROFILES=mcp" in runner def test_synthetic_acceptance_never_enables_provider_calls() -> None: @@ -51,6 +76,57 @@ def test_provider_acceptance_reuses_shared_post_eligibility_sql() -> None: assert "where ${source_post_eligibility_sql}" in runner +def test_provider_acceptance_observes_the_resumed_content_ledger() -> None: + """Acceptance must reuse current work and prove deployment-bound evidence.""" + runner = (_ROOT / "scripts" / "accept_operations_dashboard_runtime.sh").read_text( + encoding="utf-8" + ) + assert "OPERATIONS_CASE_ACCEPTANCE_TIMEOUT_SECONDS" in runner + assert "OPERATIONS_CASE_POLL_SECONDS" in runner + assert "docker inspect lineageweave-backend-worker-1" in runner + assert "{{.State.StartedAt}}" in runner + assert '-v deployment_started_at="$worker_started_at"' in runner + assert "analysis.analyzed_at >= :'deployment_started_at'::timestamptz" in runner + assert "analysis.source_body_sha256 = job.source_body_sha256" in runner + assert "'post_content_ingestion_queued'" in runner + assert "'post_content_ingestion_running'" in runner + assert "count(distinct post_id)" in runner + assert "run_operations_case_aggregate" in runner + assert "printf '%s\\n' \"$aggregate_sql\"" in runner + assert 'docker exec -i "$POSTGRES_CONTAINER"' in runner + assert '-c "$aggregate_sql"' not in runner + assert 'sleep "$OPERATIONS_CASE_POLL_SECONDS"' in runner + assert "/api/post-content/backfill" not in runner + assert "expected exactly one normalized preferred candidate" not in runner + assert "post_id=%" not in runner + + +def test_provider_acceptance_uses_bounded_async_gateway_readiness() -> None: + """Runtime acceptance must probe only the declared gateway access list.""" + runner = (_ROOT / "scripts" / "accept_operations_dashboard_runtime.sh").read_text( + encoding="utf-8" + ) + assert "ORCHESTRATOR_PROBE_TIMEOUT_SECONDS" in runner + assert "ORCHESTRATOR_READINESS_TIMEOUT_SECONDS" in runner + assert "provider_readiness/latest?refresh=true" not in runner + assert "docker exec -i" in runner + assert "-e ORCHESTRATOR_ADMIN_TOKEN" not in runner + assert "CONTEXTUAL_ORCHESTRATOR_TOKEN" in runner + assert '.provider == "configured_gateway"' in runner + assert '.status != "disabled"' in runner + assert "/api/v1/provider_readiness_refreshes" in runner + assert 'capability_code:"structured"' in runner + assert 'capability_code:"chat"' not in runner + assert 'headers["X-Request-Timeout-Ms"] = timeout_ms' in runner + assert "remaining_readiness_ms" in runner + assert "readiness_deadline - SECONDS" in runner + assert '.poll_after_ms | select(type == "number" and floor == . and . > 0)' in runner + assert 'sleep "$readiness_poll_seconds"' in runner + assert "queued|running) sleep 1" not in runner + assert "failed|cancelled|expired" in runner + assert ".ready_count > 0" in runner + + def test_runtime_runners_require_distinct_desktop_and_mobile_artifacts() -> None: """Both acceptance modes must preserve separate responsive screenshots.""" for script_name in ( @@ -67,6 +143,34 @@ def test_runtime_runners_require_distinct_desktop_and_mobile_artifacts() -> None assert '"${BACKEND_URL%/}/healthz"' in runner +def test_provider_runtime_exercises_dashboard_and_ask_evidence_navigation() -> None: + """Committed runtime acceptance preserves both evidence-bearing customer flows.""" + runner = (_ROOT / "scripts" / "accept_operations_dashboard_runtime.sh").read_text( + encoding="utf-8" + ) + dashboard_spec = (_ROOT / "frontend/e2e/runtime-operations-dashboard.spec.ts").read_text( + encoding="utf-8" + ) + ask_spec = (_ROOT / "frontend/e2e/runtime-ask-evidence.spec.ts").read_text( + encoding="utf-8" + ) + assert "e2e/runtime-operations-dashboard.spec.ts e2e/runtime-ask-evidence.spec.ts" in runner + assert "evidenceDialog" in dashboard_spec + assert "ASK_SCREENSHOT_DESKTOP_PATH" in runner + assert "ASK_SCREENSHOT_MOBILE_PATH" in runner + assert "ASK_SCREENSHOT_DESKTOP_PATH" in ask_spec + assert "ASK_SCREENSHOT_MOBILE_PATH" in ask_spec + assert "LINEAGEWEAVE_RUNTIME_ASK_QUESTION" in runner + assert "LINEAGEWEAVE_RUNTIME_ASK_TIMEOUT_SECONDS" in runner + assert "LINEAGEWEAVE_RUNTIME_ASK_TIMEOUT_SECONDS" in ask_spec + assert "MINIMUM_TOKEN_LIFETIME_SECONDS" not in ask_spec + assert "expires_at: expiresAt" in ask_spec + assert "Date.now() / 1000) +" not in ask_spec + assert "timeoutSeconds * 1000" in ask_spec + assert "< timeoutSeconds" in ask_spec + assert "620_000" not in ask_spec + + def test_acceptance_uses_only_the_checked_in_compose_file() -> None: """Host-level Compose overrides must not alter the accepted product stack.""" makefile = (_ROOT / "Makefile").read_text(encoding="utf-8") diff --git a/tests/test_temporal_journey_artifact.py b/tests/test_temporal_journey_artifact.py new file mode 100644 index 000000000..3bd08779b --- /dev/null +++ b/tests/test_temporal_journey_artifact.py @@ -0,0 +1,220 @@ +"""Typed temporal-artifact admission tests.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json + +import pytest + +from backend.app.project_journey_temporal import ( + TemporalArtifactAdmissionError, + persist_project_journey_temporal_artifact, +) +from lineageweave.temporal_journey_artifact import ( + TemporalJourneyArtifactError, + parse_temporal_journey_artifact, +) + + +def _payload(*, run_id: str = "remote-1") -> bytes: + return json.dumps( + { + "schema_version": "tepp.tdt_chronos_interval_consistency.v1", + "run_id": run_id, + "snapshot_id": "snapshot-1", + "input_digest_sha256": "a" * 64, + "relations": [{ + "left_event_id": "00000000-0000-0000-0000-000000000001", + "right_event_id": "00000000-0000-0000-0000-000000000002", + "allen_relations": ["before", "meets"], + "observed": False, + "support_assertion_ordinals": [0, 2], + }], + }, + separators=(",", ":"), + ).encode() + + +def _parse(payload: bytes): + return parse_temporal_journey_artifact( + payload, + expected_run_id="remote-1", + expected_snapshot_id="snapshot-1", + expected_input_digest_sha256="a" * 64, + expected_artifact_digest_sha256=hashlib.sha256(payload).hexdigest(), + ) + + +def test_parser_binds_canonical_bytes_and_all_identities() -> None: + """The admitted DTO retains no unbound provider field.""" + + result = _parse(_payload()) + assert result.relations[0].allen_relations == ("before", "meets") + assert result.relations[0].support_assertion_ordinals == (0, 2) + + +@pytest.mark.parametrize("mutation", ["digest", "run", "unknown", "order"]) +def test_parser_rejects_changed_or_noncanonical_artifacts(mutation: str) -> None: + """Malformed, moved, or noncanonical payloads fail closed.""" + + payload = _payload(run_id="other" if mutation == "run" else "remote-1") + if mutation == "unknown": + value = json.loads(payload) + value["extra"] = True + payload = json.dumps(value, separators=(",", ":")).encode() + if mutation == "order": + value = json.loads(payload) + value["relations"][0]["allen_relations"] = ["meets", "before"] + payload = json.dumps(value, separators=(",", ":")).encode() + digest = "b" * 64 if mutation == "digest" else hashlib.sha256(payload).hexdigest() + with pytest.raises(TemporalJourneyArtifactError): + parse_temporal_journey_artifact( + payload, + expected_run_id="remote-1", + expected_snapshot_id="snapshot-1", + expected_input_digest_sha256="a" * 64, + expected_artifact_digest_sha256=digest, + ) + + +@pytest.mark.parametrize( + ("payload", "input_digest", "artifact_digest"), + [ + (b"", "a" * 64, "0" * 64), + (b"{}", "bad", hashlib.sha256(b"{}").hexdigest()), + (b"\xff", "a" * 64, hashlib.sha256(b"\xff").hexdigest()), + (b" {\"x\":1}", "a" * 64, hashlib.sha256(b" {\"x\":1}").hexdigest()), + (b"[]", "a" * 64, hashlib.sha256(b"[]").hexdigest()), + ], +) +def test_parser_rejects_size_digest_encoding_and_top_level_shape( + payload: bytes, input_digest: str, artifact_digest: str +) -> None: + """Every outer wire boundary rejects before relation persistence.""" + + with pytest.raises(TemporalJourneyArtifactError): + parse_temporal_journey_artifact( + payload, + expected_run_id="remote-1", + expected_snapshot_id="snapshot-1", + expected_input_digest_sha256=input_digest, + expected_artifact_digest_sha256=artifact_digest, + ) + + +def test_parser_rejects_empty_and_malformed_relation_collections() -> None: + """An empty result or untyped relation is not journey evidence.""" + + for relations in ([], ["not-an-object"]): + value = json.loads(_payload()) + value["relations"] = relations + payload = json.dumps(value, separators=(",", ":")).encode() + with pytest.raises(TemporalJourneyArtifactError): + _parse(payload) + + +class _Connection: + """Capture the normalized producer statements.""" + + def __init__( + self, + remote_run_id: str = "remote-1", + existing_digest: str | None = None, + ) -> None: + self.remote_run_id = remote_run_id + self.existing_digest = existing_digest + self.execute_calls: list[tuple[str, tuple[object, ...]]] = [] + self.many_calls: list[tuple[str, list[tuple[object, ...]]]] = [] + + async def fetchrow(self, query: str, *args: object): + """Return the terminal binding and no prior artifact.""" + + if "analysis_run_tepp_result" in query: + return {"remote_run_id": self.remote_run_id} + return ( + {"artifact_digest_sha256": self.existing_digest} + if self.existing_digest is not None + else None + ) + + async def execute(self, query: str, *args: object): + """Capture artifact metadata persistence.""" + + self.execute_calls.append((query, args)) + + async def executemany(self, query: str, args: list[tuple[object, ...]]): + """Capture normalized relation children.""" + + self.many_calls.append((query, args)) + + +def test_producer_persists_relation_kinds_and_support_separately() -> None: + """One accepted artifact produces normalized, auditable rows.""" + + payload = _payload() + connection = _Connection() + asyncio.run( + persist_project_journey_temporal_artifact( + connection, + analysis_run_id="00000000-0000-0000-0000-000000000010", + payload=payload, + expected_run_id="remote-1", + expected_snapshot_id="snapshot-1", + expected_input_digest_sha256="a" * 64, + expected_artifact_digest_sha256=hashlib.sha256(payload).hexdigest(), + ) + ) + assert len(connection.execute_calls) == 1 + assert [len(rows) for _query, rows in connection.many_calls] == [1, 2, 2] + + +def test_producer_rejects_a_terminal_run_mismatch() -> None: + """A valid artifact cannot be attached to another persisted run.""" + + payload = _payload() + with pytest.raises(TemporalArtifactAdmissionError): + asyncio.run( + persist_project_journey_temporal_artifact( + _Connection("different"), + analysis_run_id="00000000-0000-0000-0000-000000000010", + payload=payload, + expected_run_id="remote-1", + expected_snapshot_id="snapshot-1", + expected_input_digest_sha256="a" * 64, + expected_artifact_digest_sha256=hashlib.sha256(payload).hexdigest(), + ) + ) + + +def test_producer_is_idempotent_and_rejects_changed_artifact() -> None: + """A run may replay identical bytes but cannot change immutable evidence.""" + + payload = _payload() + digest = hashlib.sha256(payload).hexdigest() + same = _Connection(existing_digest=digest) + asyncio.run( + persist_project_journey_temporal_artifact( + same, + analysis_run_id="00000000-0000-0000-0000-000000000010", + payload=payload, + expected_run_id="remote-1", + expected_snapshot_id="snapshot-1", + expected_input_digest_sha256="a" * 64, + expected_artifact_digest_sha256=digest, + ) + ) + assert same.execute_calls == [] + with pytest.raises(TemporalArtifactAdmissionError): + asyncio.run( + persist_project_journey_temporal_artifact( + _Connection(existing_digest="b" * 64), + analysis_run_id="00000000-0000-0000-0000-000000000010", + payload=payload, + expected_run_id="remote-1", + expected_snapshot_id="snapshot-1", + expected_input_digest_sha256="a" * 64, + expected_artifact_digest_sha256=digest, + ) + ) diff --git a/tests/test_topic_influence_client.py b/tests/test_topic_influence_client.py new file mode 100644 index 000000000..278149554 --- /dev/null +++ b/tests/test_topic_influence_client.py @@ -0,0 +1,850 @@ +"""Contract tests for TEPP-bound fast-mlsirm topic influence.""" + +from __future__ import annotations + +import copy +import asyncio +import base64 +import hashlib +import json +import uuid +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +import pytest + +from lineageweave.topic_influence_client import ( + HttpTopicInfluenceClient, + RESULT_SCHEMA_VERSION, + TopicInfluenceClient, + TopicInfluenceInvalidResponse, + build_topic_influence_request, +) +from lineageweave.http_client import HttpAdmissionDeferred +from lineageweave import topic_influence_client +from backend.app import topic_influence_worker +from backend.app.config import load_settings + +_LEASE_TOKEN = "11111111-1111-4111-8111-111111111111" + + +def _request(): + return build_topic_influence_request( + tepp_run={ + "tepp_run_id": "tepp-synthetic-1", + "tepp_artifact_sha256": "a" * 64, + "source_snapshot_sha256": "b" * 64, + "knowledge_cutoff": "2026-01-01T00:00:00+00:00", + "posterior_draw_set_id": "draws-1", + "posterior_draw_count": 2, + "coordinate_kind_code": "plausible_value", + "topic_model_run_id": "model-1", + }, + topics=[0, 1], + observations=[ + { + "post_id": "synthetic-post-1", + "event_time": "2025-12-01T00:00:00+00:00", + "coordinates": [ + {"topic_index": topic, "posterior_draw_ordinal": draw, "value": value} + for topic, draw, value in ( + (0, 0, -0.2), + (0, 1, -0.1), + (1, 0, 0.2), + (1, 1, 0.1), + ) + ], + "memberships": [ + { + "membership_id": f"membership-{index}", + "dimension_code": dimension, + "context_id": f"synthetic-{dimension}", + "weight": 1.0, + "valid_from": "2025-01-01T00:00:00+00:00", + "valid_to": "2027-01-01T00:00:00+00:00", + "evidence_sha256": "c" * 64, + "provenance_assertion_id": "assertion-1", + } + for index, dimension in enumerate( + ("business_unit", "process_unit", "team", "person"), 1 + ) + ], + } + ], + ) + + +def _artifact(request): + return { + "schema_version": RESULT_SCHEMA_VERSION, + "request_sha256": request.request_sha256, + "tepp_run_id": "tepp-synthetic-1", + "source_snapshot_sha256": "b" * 64, + "knowledge_cutoff": "2026-01-01T00:00:00+00:00", + "membership_fingerprint_sha256": request.membership_fingerprint_sha256, + "producer_version": "0.1.0", + "code_revision": "d" * 40, + "compute_backend_code": "rust_cpu", + "precision_code": "f64", + "posterior_draw_coverage": 2, + "convergence_status_code": "converged", + "identification_status_code": "identified", + "parity_status_code": "passed", + "influences": [ + { + "post_id": "synthetic-post-1", + "membership_id": f"membership-{membership}", + "topic_index": topic, + "influence_value": 0.25, + "uncertainty_method_code": "posterior_draw_interval", + "uncertainty_lower_value": 0.2, + "uncertainty_upper_value": 0.3, + "diagnostic_status_code": "accepted", + } + for membership in (1, 2, 3, 4) + for topic in (0, 1) + ], + } + + +def _response(request, artifact=None): + payload = artifact if artifact is not None else _artifact(request) + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode() + return { + "artifact_sha256": hashlib.sha256(raw).hexdigest(), + "artifact_base64": base64.b64encode(raw).decode("ascii"), + } + + +def test_client_accepts_only_complete_digest_bound_result() -> None: + """Every post-membership-topic cell remains exact and auditable.""" + request = _request() + result = TopicInfluenceClient(lambda _payload: _response(request), lease_timeout_seconds=17).estimate(request) + + assert result.payload["artifact_sha256"] == _response(request)["artifact_sha256"] + assert len(result.payload["influences"]) == 8 + + +def test_request_digest_covers_lineage_owned_raw_wire_bytes() -> None: + """The producer receives exact request bytes and echoes their opaque digest.""" + request = _request() + wire = request.to_json() + + assert set(wire) == {"request_sha256", "request_base64"} + raw = base64.b64decode(wire["request_base64"], validate=True) + assert hashlib.sha256(raw).hexdigest() == wire["request_sha256"] + assert json.loads(raw) == request.payload + membership_raw = base64.b64decode( + request.payload["membership_artifact_base64"], validate=True + ) + assert hashlib.sha256(membership_raw).hexdigest() == ( + request.membership_fingerprint_sha256 + ) + + +def test_artifact_digest_covers_producer_supplied_raw_bytes() -> None: + """Admission hashes exact producer bytes rather than reserializing floats.""" + request = _request() + first = _response(request) + differently_formatted = json.dumps(_artifact(request), indent=2).encode() + second = { + "artifact_sha256": hashlib.sha256(differently_formatted).hexdigest(), + "artifact_base64": base64.b64encode(differently_formatted).decode("ascii"), + } + + assert TopicInfluenceClient(lambda _payload: first, lease_timeout_seconds=17).estimate(request) + assert TopicInfluenceClient(lambda _payload: second, lease_timeout_seconds=17).estimate(request) + + +def test_artifact_digest_is_checked_before_json_parse() -> None: + """Tampered producer bytes fail their digest before any JSON interpretation.""" + request = _request() + response = { + "artifact_sha256": "e" * 64, + "artifact_base64": base64.b64encode(b"not-json").decode("ascii"), + } + + with pytest.raises(TopicInfluenceInvalidResponse, match="digest is invalid"): + TopicInfluenceClient( + lambda _payload: response, lease_timeout_seconds=17 + ).estimate(request) + + +@pytest.mark.parametrize("mutation", ["request", "digest", "partial", "nonfinite"]) +def test_client_rejects_mixed_or_incomplete_results(mutation: str) -> None: + """No mismatched, partial, or non-finite producer row reaches persistence.""" + request = _request() + artifact = copy.deepcopy(_artifact(request)) + response = _response(request, artifact) + if mutation == "request": + artifact["request_sha256"] = "e" * 64 + response = _response(request, artifact) + elif mutation == "digest": + response["artifact_sha256"] = "e" * 64 + elif mutation == "partial": + artifact["influences"].pop() + response = _response(request, artifact) + else: + artifact["influences"][0]["influence_value"] = "not-finite" + response = _response(request, artifact) + + with pytest.raises(TopicInfluenceInvalidResponse): + TopicInfluenceClient(lambda _payload: response, lease_timeout_seconds=17).estimate(request) + + +def test_request_rejects_incomplete_tepp_posterior_draws() -> None: + """A hard label or partial posterior cannot become fast-mlsirm input.""" + request = _request() + observations = copy.deepcopy(request.payload["observations"]) + observations[0]["coordinates"].pop() + + with pytest.raises(ValueError, match="coordinates are incomplete"): + build_topic_influence_request( + tepp_run=dict(request.payload["tepp_run"]), + topics=list(request.payload["topic_indices"]), + observations=observations, + ) + + +def test_request_accepts_time_varying_membership_slices() -> None: + """Distinct evidence rows may retain the same context across valid times.""" + request = _request() + observations = copy.deepcopy(request.payload["observations"]) + later = copy.deepcopy(observations[0]["memberships"][0]) + later["membership_id"] = "membership-later" + later["valid_from"] = "2027-01-01T00:00:00+00:00" + later["valid_to"] = "2028-01-01T00:00:00+00:00" + observations[0]["memberships"].append(later) + + accepted = build_topic_influence_request( + tepp_run=dict(request.payload["tepp_run"]), + topics=list(request.payload["topic_indices"]), + observations=observations, + ) + + assert len(accepted.payload["observations"][0]["memberships"]) == 5 + + +def test_request_requires_four_dimensions_across_run_not_each_post() -> None: + """A post carries only evidenced levels while the run covers every level.""" + request = _request() + first = copy.deepcopy(request.payload["observations"][0]) + second = copy.deepcopy(first) + first["memberships"] = first["memberships"][:2] + second["post_id"] = "synthetic-post-2" + second["memberships"] = second["memberships"][2:] + for membership in second["memberships"]: + membership["membership_id"] += "-second" + + accepted = build_topic_influence_request( + tepp_run=dict(request.payload["tepp_run"]), + topics=list(request.payload["topic_indices"]), + observations=[first, second], + ) + + assert [len(row["memberships"]) for row in accepted.payload["observations"]] == [2, 2] + + +def test_http_client_attributes_transport_to_numerical_owner(monkeypatch) -> None: + """Topic influence spans identify fast-mlsirm rather than the orchestrator.""" + request = _request() + captured: dict[str, object] = {} + + def post(_url, _payload, **kwargs): + captured.update(kwargs) + return _response(request) + + monkeypatch.setattr(topic_influence_client, "post_json", post) + HttpTopicInfluenceClient( + "https://synthetic.invalid", "", timeout=11.0, lease_timeout_seconds=17 + ).estimate(request) + + assert captured["service_peer_name"] == "fast-mlsirm" + + +def test_settings_preserve_declared_request_and_lease_contract(monkeypatch) -> None: + """Runtime timeouts come only from explicit positive deployment values.""" + monkeypatch.setenv("TOPIC_INFLUENCE_REQUEST_TIMEOUT_SECONDS", "11") + monkeypatch.setenv("TOPIC_INFLUENCE_LEASE_TIMEOUT_SECONDS", "17") + monkeypatch.setenv("TOPIC_INFLUENCE_POLL_SECONDS", "13") + + settings = load_settings() + + assert settings.topic_influence_request_timeout_seconds == 11 + assert settings.topic_influence_lease_timeout_seconds == 17 + assert settings.topic_influence_poll_seconds == 13 + + +@pytest.mark.parametrize("lease_timeout", [0, -1, 1.5, True]) +def test_client_rejects_undeclared_or_invalid_lease(lease_timeout: object) -> None: + """A worker cannot invent or weaken the provider request lease.""" + with pytest.raises(ValueError, match="positive integer"): + TopicInfluenceClient(lambda _payload: {}, lease_timeout_seconds=lease_timeout) + + +@pytest.mark.parametrize("request_timeout", [0, -1, float("inf"), True]) +def test_http_client_rejects_invalid_request_timeout(request_timeout: object) -> None: + """The outbound request contract requires a positive finite timeout.""" + with pytest.raises(ValueError, match="positive finite"): + HttpTopicInfluenceClient( + "https://synthetic.invalid", + "", + timeout=request_timeout, + lease_timeout_seconds=17, + ) + + +def test_worker_persists_one_valid_result_without_local_math(monkeypatch) -> None: + """The worker delegates once and passes the validated result to persistence.""" + request = _request() + persisted: list[tuple[str, str]] = [] + + async def claim(_pool, _lease_seconds): + return "model-1", request, _LEASE_TOKEN + + async def persist(_pool, run_id, accepted_request, result, lease_token): + persisted.append((run_id, result.payload["request_sha256"])) + assert accepted_request is request + assert lease_token == _LEASE_TOKEN + + monkeypatch.setattr(topic_influence_worker, "claim_topic_influence_job", claim) + monkeypatch.setattr(topic_influence_worker, "persist_topic_influence_result", persist) + + worked = asyncio.run( + topic_influence_worker.process_topic_influence_job( + object(), TopicInfluenceClient(lambda _payload: _response(request), lease_timeout_seconds=17) + ) + ) + + assert worked is True + assert persisted == [("model-1", request.request_sha256)] + + +def test_worker_records_invalid_result_without_persisting(monkeypatch) -> None: + """Malformed owner output becomes a bounded failed job, never a score.""" + request = _request() + failures: list[tuple[str, str]] = [] + + async def claim(_pool, _lease_seconds): + return "model-1", request, _LEASE_TOKEN + + async def fail(_pool, run_id, lease_token, code): + assert lease_token == _LEASE_TOKEN + failures.append((run_id, code)) + + async def forbidden(*_args): + raise AssertionError("invalid result reached persistence") + + monkeypatch.setattr(topic_influence_worker, "claim_topic_influence_job", claim) + monkeypatch.setattr(topic_influence_worker, "_fail_job", fail) + monkeypatch.setattr(topic_influence_worker, "persist_topic_influence_result", forbidden) + + worked = asyncio.run( + topic_influence_worker.process_topic_influence_job( + object(), TopicInfluenceClient(lambda _payload: {}, lease_timeout_seconds=17) + ) + ) + + assert worked is True + assert failures == [("model-1", "producer_result_invalid")] + + +def test_worker_distinguishes_unavailable_transport(monkeypatch) -> None: + """Transport outage remains distinct from rejected scientific evidence.""" + request = _request() + failures: list[tuple[str, str]] = [] + + async def claim(_pool, _lease_seconds): + return "model-1", request, _LEASE_TOKEN + + async def fail(_pool, run_id, lease_token, code): + assert lease_token == _LEASE_TOKEN + failures.append((run_id, code)) + + def unavailable(_payload): + raise OSError("synthetic transport unavailable") + + monkeypatch.setattr(topic_influence_worker, "claim_topic_influence_job", claim) + monkeypatch.setattr(topic_influence_worker, "_fail_job", fail) + + asyncio.run( + topic_influence_worker.process_topic_influence_job( + object(), TopicInfluenceClient(unavailable, lease_timeout_seconds=17) + ) + ) + + assert failures == [("model-1", "producer_unavailable")] + + +def test_worker_uses_exact_remote_retry_delay(monkeypatch) -> None: + """A remote admission delay requeues exactly, without invented backoff.""" + request = _request() + deferred: list[tuple[str, int]] = [] + + async def claim(_pool, _lease_seconds): + return "model-1", request, _LEASE_TOKEN + + async def defer(_pool, run_id, lease_token, seconds): + assert lease_token == _LEASE_TOKEN + deferred.append((run_id, seconds)) + + def unavailable(_payload): + raise HttpAdmissionDeferred(17) + + monkeypatch.setattr(topic_influence_worker, "claim_topic_influence_job", claim) + monkeypatch.setattr(topic_influence_worker, "_defer_job", defer) + + asyncio.run( + topic_influence_worker.process_topic_influence_job( + object(), TopicInfluenceClient(unavailable, lease_timeout_seconds=17) + ) + ) + + assert deferred == [("model-1", 17)] + + +def test_worker_releases_changed_input_for_a_fresh_request(monkeypatch) -> None: + """A changed digest is re-leased instead of becoming operator-only failure.""" + request = _request() + released: list[str] = [] + + async def claim(_pool, _lease_seconds): + return "model-1", request, _LEASE_TOKEN + + async def changed(*_args): + raise topic_influence_worker.TopicInfluenceInputChanged("changed") + + async def release(_pool, run_id, lease_token): + assert lease_token == _LEASE_TOKEN + released.append(run_id) + + monkeypatch.setattr(topic_influence_worker, "claim_topic_influence_job", claim) + monkeypatch.setattr(topic_influence_worker, "persist_topic_influence_result", changed) + monkeypatch.setattr(topic_influence_worker, "_release_changed_job", release) + + asyncio.run( + topic_influence_worker.process_topic_influence_job( + object(), + TopicInfluenceClient( + lambda _payload: _response(request), lease_timeout_seconds=17 + ), + ) + ) + + assert released == ["model-1"] + + +def test_worker_discards_a_result_after_losing_its_exact_lease(monkeypatch) -> None: + """A stale result cannot relabel or mutate the replacement worker's lease.""" + request = _request() + failures: list[str] = [] + + async def claim(_pool, _lease_seconds): + return "model-1", request, _LEASE_TOKEN + + async def persist(*_args): + raise topic_influence_worker.TopicInfluenceLeaseLost("synthetic reclaim") + + async def fail(*_args): + failures.append("failed") + + monkeypatch.setattr(topic_influence_worker, "claim_topic_influence_job", claim) + monkeypatch.setattr(topic_influence_worker, "persist_topic_influence_result", persist) + monkeypatch.setattr(topic_influence_worker, "_fail_job", fail) + + worked = asyncio.run( + topic_influence_worker.process_topic_influence_job( + object(), + TopicInfluenceClient( + lambda _payload: _response(request), lease_timeout_seconds=17 + ), + ) + ) + + assert worked is True + assert failures == [] + + +@pytest.mark.parametrize( + "failure", + [ + topic_influence_worker.asyncpg.PostgresError("synthetic unavailable"), + OSError("synthetic connection unavailable"), + TimeoutError("synthetic connection timeout"), + ], +) +def test_worker_retries_transient_claim_database_failure( + monkeypatch, failure: Exception +) -> None: + """One transient claim failure cannot terminate the durable consumer task.""" + calls: list[str] = [] + + async def process(_pool, _client): + calls.append("process") + if calls.count("process") == 1: + raise failure + raise asyncio.CancelledError + + async def sleep(seconds): + assert seconds == 13 + calls.append("sleep") + + monkeypatch.setattr(topic_influence_worker, "process_topic_influence_job", process) + monkeypatch.setattr(topic_influence_worker.asyncio, "sleep", sleep) + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + topic_influence_worker.run_topic_influence_worker( + object(), lambda: object(), poll_seconds=13 + ) + ) + + assert calls == ["process", "sleep", "process"] + + +@pytest.mark.parametrize( + "incomplete_error", + [ + ValueError("synthetic incomplete evidence"), + TypeError("synthetic invalid evidence type"), + KeyError("synthetic missing evidence field"), + ], +) +def test_claim_scans_past_incomplete_evidence( + monkeypatch, incomplete_error: Exception +) -> None: + """Older incomplete requests cannot starve a later complete request.""" + request = _request() + statements: list[str] = [] + + class Connection: + async def execute(self, sql, *_args): + statements.append(sql) + return "UPDATE 1" + + async def fetch(self, sql): + assert "limit 10" not in sql.lower() + assert "not_before <= clock_timestamp()" in sql + return [ + {"topic_model_run_id": f"incomplete-{index}"} + for index in range(11) + ] + [{"topic_model_run_id": "complete"}] + + def transaction(self): + return _async_context(self) + + async def fetchval( + self, _sql, run_id, _digest, _lease_seconds, _lease_token + ): + return run_id + + class Pool: + def acquire(self): + return _async_context(Connection()) + + async def load(_conn, run_id): + if run_id != "complete": + raise incomplete_error + return request + + monkeypatch.setattr(topic_influence_worker, "load_topic_influence_request", load) + + claimed = asyncio.run(topic_influence_worker.claim_topic_influence_job(Pool(), 17)) + + assert claimed is not None + assert claimed[:2] == ("complete", request) + assert uuid.UUID(claimed[2]) + assert any("lease_expires_at <= clock_timestamp()" in sql for sql in statements) + assert any( + "lease_expires_at <= clock_timestamp()" in sql + and "request_sha256 = null" in sql + for sql in statements + ) + assert sum("awaiting_evidence" in sql for sql in statements) == 11 + + +def test_claim_requeues_evidence_that_commits_before_awaiting_transition( + monkeypatch, +) -> None: + """The post-transition recheck closes the otherwise lost wakeup window.""" + statements: list[str] = [] + loads = 0 + + class Connection: + async def execute(self, sql, *_args): + statements.append(sql) + return "UPDATE 1" + + async def fetch(self, _sql): + return [{"topic_model_run_id": "model-1"}] + + class Pool: + def acquire(self): + return _async_context(Connection()) + + async def load(_conn, _run_id): + nonlocal loads + loads += 1 + if loads == 1: + raise ValueError("synthetic evidence not committed") + return _request() + + monkeypatch.setattr(topic_influence_worker, "load_topic_influence_request", load) + + assert asyncio.run(topic_influence_worker.claim_topic_influence_job(Pool(), 17)) is None + assert loads == 2 + assert any("status_code = 'awaiting_evidence'" in sql for sql in statements) + assert any( + "status_code = 'queued'" in sql and "status_code = 'awaiting_evidence'" in sql + for sql in statements + ) + + +def test_loader_requires_the_accepted_normalized_tepp_projection() -> None: + """The accepted posterior projection, not an older result table, is admitted.""" + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + + class Connection: + async def fetchval(self, sql, *_args): + assert "assertion.assertion_id is null" in sql + assert "evidence.resource_id is null" in sql + return False + + async def fetchrow(self, sql, *_args): + assert "analysis_run_tepp_receipt" not in sql + assert "analysis_run_topic_lineage_result" not in sql + assert "model.tepp_schema_version = 'tepp.topic_context_posterior.v1'" in sql + return { + "topic_model_run_id": "model-1", + "tepp_run_id": "tepp-synthetic-1", + "tepp_artifact_sha256": "a" * 64, + "posterior_draw_set_id": "draws-1", + "posterior_draw_count": 2, + "coordinate_kind_code": "plausible_value", + "snapshot_sha256": "b" * 64, + "knowledge_cutoff": now, + } + + async def fetch(self, sql, *_args): + if "from topic_definition" in sql: + return [{"topic_index": 0}, {"topic_index": 1}] + if "select distinct membership.source_post_id" in sql: + return [{"source_post_id": "post-1", "event_time": now}] + if "from topic_post_coordinate" in sql: + return [ + { + "topic_index": topic, + "posterior_draw_ordinal": draw, + "coordinate_value": value, + } + for topic, draw, value in ( + (0, 0, -0.2), + (0, 1, -0.1), + (1, 0, 0.2), + (1, 1, 0.1), + ) + ] + return [ + { + "topic_context_membership_id": f"membership-{index}", + "dimension_code": dimension, + "context_id": f"synthetic-{dimension}", + "membership_weight": 1.0, + "valid_from": now, + "valid_to": datetime(2027, 1, 1, tzinfo=timezone.utc), + "evidence_sha256": "c" * 64, + "provenance_assertion_id": f"assertion-{index}", + } + for index, dimension in enumerate( + ("business_unit", "process_unit", "team", "person"), 1 + ) + ] + + request = asyncio.run( + topic_influence_worker.load_topic_influence_request(Connection(), "model-1") + ) + + assert request.payload["tepp_run"]["tepp_artifact_sha256"] == "a" * 64 + assert len(request.payload["observations"][0]["memberships"]) == 4 + + +def test_loader_rejects_a_partially_bound_membership_set() -> None: + """One missing provenance binding cannot silently narrow the fitted run.""" + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + + class Connection: + async def fetchrow(self, _sql, *_args): + return { + "topic_model_run_id": "model-1", + "tepp_run_id": "tepp-synthetic-1", + "tepp_artifact_sha256": "a" * 64, + "posterior_draw_set_id": "draws-1", + "posterior_draw_count": 1, + "coordinate_kind_code": "plausible_value", + "snapshot_sha256": "b" * 64, + "knowledge_cutoff": now, + } + + async def fetch(self, sql, *_args): + if "from topic_definition" in sql: + return [{"topic_index": 0}] + if "select distinct membership.source_post_id" in sql: + return [{"source_post_id": "post-1", "event_time": now}] + raise AssertionError("membership rows must not load after the failed fence") + + async def fetchval(self, sql, *_args): + assert "left join provenance_resource_binding" in sql + return True + + with pytest.raises(ValueError, match="provenance is incomplete"): + asyncio.run( + topic_influence_worker.load_topic_influence_request( + Connection(), "model-1" + ) + ) + + +def test_persistence_rechecks_digest_and_writes_every_validated_row(monkeypatch) -> None: + """The short transaction stores the run, all rows, and terminal lease.""" + request = _request() + result = TopicInfluenceClient(lambda _payload: _response(request), lease_timeout_seconds=17).estimate(request) + + class Connection: + def __init__(self): + self.executed: list[str] = [] + + def transaction(self): + return _async_context(self) + + async def fetchrow(self, _sql, *_args): + return { + "request_sha256": request.request_sha256, + "lease_token": _LEASE_TOKEN, + } + + async def fetchval(self, sql, *_args): + self.executed.append(sql) + return "influence-run-1" + + async def execute(self, sql, *_args): + self.executed.append(sql) + + connection = Connection() + + class Pool: + def acquire(self): + return _async_context(connection) + + async def current(_conn, _run_id): + return request + + monkeypatch.setattr(topic_influence_worker, "load_topic_influence_request", current) + + asyncio.run( + topic_influence_worker.persist_topic_influence_result( + Pool(), "model-1", request, result, _LEASE_TOKEN + ) + ) + + influence_inserts = sum( + "insert into topic_post_context_influence" in sql + for sql in connection.executed + ) + assert influence_inserts == 8 + assert any("status_code = 'succeeded'" in sql for sql in connection.executed) + assert any("lease_token = $2::uuid" in sql for sql in connection.executed) + + +@pytest.mark.parametrize("error_type", [ValueError, TypeError, KeyError]) +def test_persistence_treats_newly_incomplete_evidence_as_changed_input( + monkeypatch, error_type: type[Exception], +) -> None: + """Evidence withdrawn during compute must return to automatic admission.""" + request = _request() + result = TopicInfluenceClient( + lambda _payload: _response(request), lease_timeout_seconds=17 + ).estimate(request) + + class Connection: + def transaction(self): + return _async_context(self) + + async def fetchrow(self, _sql, *_args): + return { + "request_sha256": request.request_sha256, + "lease_token": _LEASE_TOKEN, + } + + class Pool: + def acquire(self): + return _async_context(Connection()) + + async def incomplete(_conn, _run_id): + raise error_type("synthetic evidence withdrawn") + + monkeypatch.setattr( + topic_influence_worker, "load_topic_influence_request", incomplete + ) + + with pytest.raises(topic_influence_worker.TopicInfluenceInputChanged): + asyncio.run( + topic_influence_worker.persist_topic_influence_result( + Pool(), "model-1", request, result, _LEASE_TOKEN + ) + ) + + +def test_every_running_transition_is_bound_to_the_exact_lease() -> None: + """A stale worker cannot fail, defer, or release a replacement lease.""" + statements: list[tuple[str, tuple[object, ...]]] = [] + + class Connection: + async def execute(self, sql, *args): + statements.append((sql, args)) + + class Pool: + def acquire(self): + return _async_context(Connection()) + + async def exercise() -> None: + await topic_influence_worker._fail_job( + Pool(), "model-1", _LEASE_TOKEN, "producer_unavailable" + ) + await topic_influence_worker._defer_job( + Pool(), "model-1", _LEASE_TOKEN, 17 + ) + await topic_influence_worker._release_changed_job( + Pool(), "model-1", _LEASE_TOKEN + ) + + asyncio.run(exercise()) + + assert len(statements) == 3 + assert all("lease_token = $2::uuid" in sql for sql, _args in statements) + assert all("request_sha256 = null" in sql for sql, _args in statements) + assert all(args[1] == _LEASE_TOKEN for _sql, args in statements) + + +def test_operator_requeue_clears_the_failed_request_identity() -> None: + """A fresh operator admission cannot retain the failed attempt digest.""" + statements: list[str] = [] + + class Connection: + async def fetchval(self, sql, *_args): + statements.append(sql) + return "model-1" + + class Pool: + def acquire(self): + return _async_context(Connection()) + + assert asyncio.run( + topic_influence_worker.requeue_topic_influence_job(Pool(), "model-1") + ) + assert "request_sha256 = null" in statements[0] + + +@asynccontextmanager +async def _async_context(value): + """Yield one async context-manager test double.""" + yield value