From 5c8cb1b3f4bc2fba3851eccb8d2c138cf496e500 Mon Sep 17 00:00:00 2001 From: THOTH Date: Sat, 10 Oct 2026 17:15:33 +0900 Subject: [PATCH] Hypothesis contract v3 quality, readable Korean text, retry on by default, and first-run fixes - v3 hypothesis tables: every hypothesis gets a prediction per test, one cause per column, tests chosen to fix or measure open conditions, alternatives that exclude one another, and a separate call that fills open cells with a recorded reason - Readable Korean: Hangul-only rule for every user-visible model field, foreign-script items dropped or flagged with a record, katakana middle dot shown as the Korean one - Interrupted model calls retry by default (at most two extra sends); remote request identifiers are recorded for broken streams - Trace investigations get comparison conditions and CSV rows; trace and import messages pick Korean particles by sound - Settings: web consent can be chosen when projects already exist; account status says unverified execution once - Demo script reads field-specific names from the demo manifest (radar output unchanged) --- AGENTS.md | 4 +- SOURCE_MANIFEST.json | 372 ++-- .../src/components/AutoRetrySwitch.test.tsx | 21 +- .../src/components/ContractV3Screens.test.tsx | 7 + apps/web/src/components/EvidenceRail.test.tsx | 23 + apps/web/src/components/ExpectedResults.tsx | 4 +- .../web/src/components/FirstRunSetup.test.tsx | 4 +- .../src/components/LiveProjectWorkspace.tsx | 12 +- .../LiveProjectWorkspaceConsent.test.tsx | 110 + apps/web/src/components/ResearchComposer.tsx | 18 +- apps/web/src/components/TracePage.test.tsx | 12 +- .../components/WorkspaceInternetConsent.tsx | 36 + apps/web/src/components/XaiDeviceLogin.tsx | 2 +- .../components/modelCredentialPresentation.ts | 42 +- .../modelCredentialStatusNote.test.ts | 55 + .../web/src/components/traceDemo.fixture.json | 2 +- .../src/components/traceInvestigation.test.ts | 2 +- apps/web/src/components/traceRows.ts | 2 +- apps/web/src/components/traceText.test.ts | 32 +- apps/web/src/components/traceText.ts | 13 +- config/module-responsibility-budget.json | 8 +- .../research-context-completion-contract.md | 8 +- scripts/hypothesis_v3_metrics.py | 160 +- scripts/prepare_synthetic_radar_demo.py | 119 +- scripts/update_source_manifest.py | 119 +- src/thoth/adapters/models/codex_http.py | 2 +- src/thoth/adapters/models/codex_oauth.py | 24 +- .../models/hypothesis_contract_text.py | 126 +- src/thoth/adapters/models/receive_stats.py | 5 +- .../adapters/models/transport_diagnostic.py | 30 +- .../adapters/models/transport_message.py | 152 ++ src/thoth/adapters/parsers/csv_parser.py | 83 +- src/thoth/adapters/parsers/text_parser.py | 48 +- .../services/acquisition_coordinator.py | 88 +- .../services/counter_search_calculations.py | 104 + .../critical_counter_search_coordinator.py | 75 +- .../services/hypothesis_contract_v3.py | 204 +- .../services/hypothesis_projection.py | 4 + .../services/hypothesis_table_filler.py | 368 ++++ .../services/memory_query_expansion.py | 2 +- .../services/memory_relation_judge.py | 2 +- .../application/services/research_analysis.py | 10 +- .../services/research_attempt_runner.py | 5 +- .../services/research_retrieval.py | 55 +- src/thoth/application/services/text_script.py | 64 + .../application/services/trace_origin.py | 106 +- .../application/workflows/thread_cycle.py | 1761 ++++++++--------- .../workflows/thread_cycle_preparation.py | 38 + src/thoth/domain/enums.py | 1 + src/thoth/domain/hypothesis.py | 69 + src/thoth/domain/model_call_settings.py | 9 +- src/thoth/domain/model_dispatch.py | 9 + src/thoth/domain/research_execution.py | 3 + src/thoth/domain/research_projection.py | 11 +- src/thoth/domain/trace_origin.py | 26 + tests/architecture/public_source_profile.py | 25 +- .../test_public_source_profile.py | 2 +- ...est_acquisition_io_transaction_boundary.py | 80 + tests/integration/test_evidence_coverage.py | 220 ++ .../test_hypothesis_contract_v3_run.py | 185 +- tests/integration/test_model_call_settings.py | 134 +- .../test_prepare_synthetic_radar_demo.py | 9 +- .../test_thread_cycle_preparation_contract.py | 221 +++ .../integration/test_trace_origin_siblings.py | 264 +++ .../test_transport_diagnostic_persistence.py | 12 +- .../test_hypothesis_table_filler.py | 528 +++++ .../test_expected_label_language_settle.py | 88 + .../domain/test_hypothesis_contract_v3.py | 2 +- .../domain/test_refutation_language_settle.py | 182 ++ .../test_visible_text_language_settle.py | 221 +++ .../test_hypothesis_contract_wire_text.py | 188 +- .../test_hypothesis_generator_opening.py | 53 + .../test_hypothesis_table_filler_wire_text.py | 114 ++ .../unit/models/test_transport_diagnostic.py | 7 +- .../test_transport_diagnostic_privacy.py | 175 ++ .../test_user_visible_language_contract.py | 36 + .../acquisition_coordinator_support.py | 395 ++++ tests/unit/services/counter_wave_support.py | 327 +++ .../test_acquisition_coordinator_contract.py | 196 ++ .../services/test_counter_wave_contract.py | 197 ++ tests/unit/test_csv_parser_rows.py | 113 ++ tests/unit/test_hypothesis_v3_metrics.py | 204 +- tests/unit/test_prepare_demo_manifest_spec.py | 132 ++ .../test_research_structured_block_context.py | 145 ++ tests/unit/test_source_manifest_git_bytes.py | 213 ++ tests/unit/test_text_parser_key_paths.py | 85 + tests/unit/test_trace_origin_context.py | 8 + 87 files changed, 8138 insertions(+), 1294 deletions(-) create mode 100644 apps/web/src/components/EvidenceRail.test.tsx create mode 100644 apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx create mode 100644 apps/web/src/components/WorkspaceInternetConsent.tsx create mode 100644 apps/web/src/components/modelCredentialStatusNote.test.ts create mode 100644 src/thoth/adapters/models/transport_message.py create mode 100644 src/thoth/application/services/counter_search_calculations.py create mode 100644 src/thoth/application/services/hypothesis_table_filler.py create mode 100644 src/thoth/application/services/text_script.py create mode 100644 src/thoth/application/workflows/thread_cycle_preparation.py create mode 100644 tests/integration/test_acquisition_io_transaction_boundary.py create mode 100644 tests/integration/test_evidence_coverage.py create mode 100644 tests/integration/test_thread_cycle_preparation_contract.py create mode 100644 tests/integration/test_trace_origin_siblings.py create mode 100644 tests/unit/application/test_hypothesis_table_filler.py create mode 100644 tests/unit/domain/test_expected_label_language_settle.py create mode 100644 tests/unit/domain/test_refutation_language_settle.py create mode 100644 tests/unit/domain/test_visible_text_language_settle.py create mode 100644 tests/unit/models/test_hypothesis_generator_opening.py create mode 100644 tests/unit/models/test_hypothesis_table_filler_wire_text.py create mode 100644 tests/unit/models/test_transport_diagnostic_privacy.py create mode 100644 tests/unit/models/test_user_visible_language_contract.py create mode 100644 tests/unit/services/acquisition_coordinator_support.py create mode 100644 tests/unit/services/counter_wave_support.py create mode 100644 tests/unit/services/test_acquisition_coordinator_contract.py create mode 100644 tests/unit/services/test_counter_wave_contract.py create mode 100644 tests/unit/test_csv_parser_rows.py create mode 100644 tests/unit/test_prepare_demo_manifest_spec.py create mode 100644 tests/unit/test_research_structured_block_context.py create mode 100644 tests/unit/test_source_manifest_git_bytes.py create mode 100644 tests/unit/test_text_parser_key_paths.py diff --git a/AGENTS.md b/AGENTS.md index a768cc5..c318236 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,8 +51,8 @@ - 버그 수정은 별도 입력이나 실패 주입으로 재현하고 원하는 동작을 회귀 테스트에 남긴다. 새 기능은 정상 사용자 진입→소비자→저장 결과를 확인한다. 테스트 double의 고정 답은 live 의미 품질 증거가 아니다. - 현재 적용되는 검증 정책을 따르되, 개발 중에는 영향 범위의 검사부터 수행한다. 동일 source의 살아 있는 검증을 중복 실행하지 않는다. 검사 범위를 줄이려면 기존 정책을 조용히 무시하지 말고 변경안을 명시한다. -- 개발 단계의 기본 검사는 가볍게 한다. 각 단계에서는 바꾼 코드의 테스트와 해당 언어의 lint(웹 lint·typecheck, Python ruff check·format)만 돌린다. 여러 화면·호출자가 함께 쓰는 컴포넌트나 모듈을 바꿨을 때만 그 소비자 테스트 또는 웹 테스트 전체를 추가한다. 빌드·architecture·basedpyright는 import 경계나 타입 계약을 바꾼 경우에만 돌린다. -- 병합·공개 전에는 깨끗한 checkout에서 제품 확인(Python 제품 시험 전체, 웹 시험 전체, 웹 lint·typecheck·build, Python ruff check, 계층 검사, README 빠른 시작)을 한 번 실행하고, 결과를 보고 사용자가 병합을 승인한다. 비활성 개발 Hook·과정 점검 시험은 명시 목록에 따라 제외하고 제외 이유와 수를 보고한다. 실패·중단·NOT_RUN을 통과로 표현하지 않는다. +- 개발 단계의 기본 검사는 가볍게 한다. 각 단계에서는 바꾼 코드의 테스트와 해당 언어의 lint(웹 lint·typecheck, Python ruff check·format)만 돌린다. 여러 화면·호출자가 함께 쓰는 컴포넌트나 모듈을 바꿨을 때만 그 소비자 테스트 또는 웹 테스트 전체를 추가한다. 빌드·architecture·basedpyright는 import 경계나 타입 계약을 바꾼 경우에만 돌린다. 서버 Python 파일을 옮기거나 나누면 서버 코드를 직접 읽는 웹 시험(예: traceReasonTags.test.ts, history/historyReasonText.test.ts)도 함께 돌린다. +- 병합·공개 전 검사는 마지막 공개본 이후 바뀐 것에 맞춘다. 제품 코드(src, apps/web/src의 시험 아닌 파일, migrations)가 바뀌었으면 깨끗한 checkout에서 제품 확인(Python 제품 시험 전체, 웹 시험 전체, 웹 lint·typecheck·build, Python ruff check, 계층 검사, README 빠른 시작)을 한 번 실행한다. 시험·검사 스크립트·설정만 바뀌었으면 그 시험과 관련 검사를 돌린다. 문서만 바뀌었으면 공개 PR의 빠른 공개 검사로 충분하다. 결과를 보고 사용자가 병합을 승인한다. 비활성 개발 Hook·과정 점검 시험은 명시 목록에 따라 제외하고 제외 이유와 수를 보고한다. 실패·중단·NOT_RUN을 통과로 표현하지 않는다. - 실패/중단/NOT_RUN을 정확히 보고한다. 관련 테스트 PASS와 전체 회귀 PASS, controlled D4와 live D5를 구분한다. 파일/RPC/테스트 개수를 완성률로 쓰지 않는다. - step 완료는 key 존재가 아니라 exact input revision·실제 상태 전이·후속 소비로 증명한다. NOT_TRIGGERED/NO_CHANGE/NOT_RUN을 SUCCESS와 구분한다. - 인계에는 해결한 기능, 실제 검증, 미완료/차단, 관련 source와 기록 위치를 남긴다. 제품 의미가 바뀌면 정본을 먼저, 해당 Wiki projection을 다음으로 갱신한다. 상태만 바뀌면 NOW/검증 기록만 갱신한다. 이 기록은 커밋·병합·세션 인계처럼 넘기는 지점에서 짧게 남기고, 개발 중간 단계마다 쓰지 않는다. diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index b63fef6..dabf1bf 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-10-08T08:01:31.524996+00:00", + "created_at": "2026-10-10T08:13:11.279804+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -28,8 +28,8 @@ }, { "path": "AGENTS.md", - "sha256": "0ce3eab6e2adc3dc2fc03bcebcc65e732d8d223dbcafe0d2214d70a8bc8173c3", - "bytes": 10702 + "sha256": "38de5796053566350ca30efd037020f231a9c10b7d609e8a71985eb1c88aa1ca", + "bytes": 11195 }, { "path": "CONTRIBUTING.md", @@ -488,8 +488,8 @@ }, { "path": "apps/web/src/components/AutoRetrySwitch.test.tsx", - "sha256": "af9da06d8bbda53c0466b106a9b8ee8f7149cada6dc47c1a2d1d9238ee5b742d", - "bytes": 3343 + "sha256": "fc881fb4ebb2c5e12d0683c0e7907b24394f7fcc0661cf9b7bc39fb8c8959e80", + "bytes": 3848 }, { "path": "apps/web/src/components/AutoRetrySwitch.tsx", @@ -543,8 +543,8 @@ }, { "path": "apps/web/src/components/ContractV3Screens.test.tsx", - "sha256": "d7f81d419b5f015906c4b562b23d748894a0045e970c9d32f5768f5a986ce5fb", - "bytes": 6723 + "sha256": "ac7213a8ddf9973cc5c3afd1b905dd1866e05a3af8b385e942b37c2ec4a87f77", + "bytes": 7243 }, { "path": "apps/web/src/components/ConversationTimeline.tsx", @@ -591,6 +591,11 @@ "sha256": "9f7b628d23f7e8c1db62762706a666336ecf99ed4a04020a9314dc9171187681", "bytes": 1548 }, + { + "path": "apps/web/src/components/EvidenceRail.test.tsx", + "sha256": "f7d4cee0368f4f146b8d9126ef8b58d7da3f9b48d6f77f9c73a6f08bcbfbe9a9", + "bytes": 1157 + }, { "path": "apps/web/src/components/EvidenceRail.tsx", "sha256": "b85594f9b5c963f816b79013741c587696fe585506a4185875988088424c7b0e", @@ -613,8 +618,8 @@ }, { "path": "apps/web/src/components/ExpectedResults.tsx", - "sha256": "25cd1525290015798b6022f69044c031cdc92668de8b37cfce4f63b7a99257fd", - "bytes": 1159 + "sha256": "3d0fdcae3969e2b8926316bf1196947c5c490c88164888b3f4ccc261bcba27d4", + "bytes": 1275 }, { "path": "apps/web/src/components/FileConnectionPanel.tsx", @@ -623,8 +628,8 @@ }, { "path": "apps/web/src/components/FirstRunSetup.test.tsx", - "sha256": "21ce1a85a94a26d9afb2d749db4706982d46bd24bd57cbfa8e17d213fb1b5e27", - "bytes": 96271 + "sha256": "a365d4409af1052473546be72e8bfbe8190b5d8511e624da13c527864804cddf", + "bytes": 96473 }, { "path": "apps/web/src/components/FirstRunSetup.tsx", @@ -708,8 +713,13 @@ }, { "path": "apps/web/src/components/LiveProjectWorkspace.tsx", - "sha256": "6ce64e0744afde57fd97362ddabb2c3cf299c92d366a613af055cfa1578f386b", - "bytes": 42234 + "sha256": "09114b62bea55d41764f811fa38932b92bb97d5e6a8b4f330afb99db50fed028", + "bytes": 42864 + }, + { + "path": "apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx", + "sha256": "f4dbf9590e5109c7a5bbc989db31259444370568fa25d1b949665368e4145d5f", + "bytes": 7736 }, { "path": "apps/web/src/components/LiveProjectWorkspaceInvestigate.test.tsx", @@ -813,8 +823,8 @@ }, { "path": "apps/web/src/components/ResearchComposer.tsx", - "sha256": "5103f2d21fc0eadc6c5250184194db70c153b18e98b2344d866d4bfdc9ba10a7", - "bytes": 5466 + "sha256": "9225b76e4ddf4313cbf17ecf5c0e4f444ab9d1e5f551a39bdb48a7d53563168b", + "bytes": 6326 }, { "path": "apps/web/src/components/ResearchDetailPane.tsx", @@ -993,8 +1003,8 @@ }, { "path": "apps/web/src/components/TracePage.test.tsx", - "sha256": "e28966b8bb0d6ce1eaf46160c0548726f6b0120f833d934d5de47ce8d7965348", - "bytes": 30119 + "sha256": "f50ff30012803c0c52ded651f77b5cccc8e08f0e3144e1ea02714c28ac827b1b", + "bytes": 30721 }, { "path": "apps/web/src/components/TracePage.tsx", @@ -1011,10 +1021,15 @@ "sha256": "1dd1e8d4388283b6a99516f15c7920ae5c524d2cc53d5d47563ab346ba4e2102", "bytes": 1223 }, + { + "path": "apps/web/src/components/WorkspaceInternetConsent.tsx", + "sha256": "ae2118d3e83797698058410869859cd7266d561ce4533b1f5beb10fd7196464c", + "bytes": 2628 + }, { "path": "apps/web/src/components/XaiDeviceLogin.tsx", - "sha256": "cf3a225903c783285919a14876a60470669e6f96cff0612327efc9547f1abc0b", - "bytes": 14439 + "sha256": "3e9d97020d952c0f3f930896d93bd17cf12700ae4b5e970d49497acfe7aea5ec", + "bytes": 14374 }, { "path": "apps/web/src/components/answerText.test.ts", @@ -1233,8 +1248,13 @@ }, { "path": "apps/web/src/components/modelCredentialPresentation.ts", - "sha256": "56f9cd0351756f39bfcda34b57e0040319ec6783b9376ed4ca24efdae195199f", - "bytes": 14523 + "sha256": "43eacda77e918641dcf4bd59c813be4d4e65dd4aa4095d9a09e43094681c8550", + "bytes": 15449 + }, + { + "path": "apps/web/src/components/modelCredentialStatusNote.test.ts", + "sha256": "a8983a22559327400231fcf4293917315a140c8e39202f379c66adc5c86a2901", + "bytes": 3989 }, { "path": "apps/web/src/components/plainWording.ts", @@ -1288,8 +1308,8 @@ }, { "path": "apps/web/src/components/traceDemo.fixture.json", - "sha256": "995aaac5187036cf265e3095fdb12ab1171d2988d2782a380d785d11f9c7b489", - "bytes": 157994 + "sha256": "67a2ded77e8510b848210c7deaaa1458a4c143eca2e27d18897be2a729b7c40c", + "bytes": 162986 }, { "path": "apps/web/src/components/traceEvaluation.test.ts", @@ -1298,8 +1318,8 @@ }, { "path": "apps/web/src/components/traceInvestigation.test.ts", - "sha256": "4148cd9b6c3bb786e1ea10ebcfee22165f7e1fc68f49b731a2737177651c4f2f", - "bytes": 5867 + "sha256": "03671342b27f78c15f4d5652ae5aee939942b0741d0d925ed0b81f4a4b8bf630", + "bytes": 5865 }, { "path": "apps/web/src/components/traceInvestigation.ts", @@ -1318,18 +1338,18 @@ }, { "path": "apps/web/src/components/traceRows.ts", - "sha256": "7eb1c8d8ddcfe3e644d73823592d2b314a4165c1e65869f1a7261d69617cb3b6", - "bytes": 3160 + "sha256": "7fe5376df231a59a6560a25976630b1b742fd7257aaa39f14baa88b169caff60", + "bytes": 3125 }, { "path": "apps/web/src/components/traceText.test.ts", - "sha256": "55cd011b4537a60c1b8c066946057e17ea23fe47553bf49ec867084f54c00114", - "bytes": 12760 + "sha256": "8aa77719d7b3e9592276aec97837cdc7865c0061970953c30f49e7bd06c3f9bd", + "bytes": 14992 }, { "path": "apps/web/src/components/traceText.ts", - "sha256": "e2f61801dd224f443b1c67011c5308dad0c57da575a970a2c7bec6c9b8dc88fa", - "bytes": 21098 + "sha256": "8c3ec012ce3caceed79fefb78c809624b8075bc65bade54953ff1dc636cf62bb", + "bytes": 21184 }, { "path": "apps/web/src/components/useConversationReadProtection.ts", @@ -1493,8 +1513,8 @@ }, { "path": "config/module-responsibility-budget.json", - "sha256": "a04e5452ecd70caa5e7472f2ef8fe684c0cf0e52f626479d3916505eb33650ef", - "bytes": 16733 + "sha256": "6ec167786cdea9043243a5f2c2ced24560892261dbd5b946e600ff17cf311b36", + "bytes": 16573 }, { "path": "config/post-d4-baseline.json", @@ -1658,8 +1678,8 @@ }, { "path": "docs/architecture/research-context-completion-contract.md", - "sha256": "43601e8613431e53ab1b1b53db6c3ae6555868caf3a6dbfac009b9387525597d", - "bytes": 7991 + "sha256": "7cbe8094a1cff0327b50cbb95302d8662cccc018c25f59e720c9894243c2dad8", + "bytes": 8136 }, { "path": "docs/architecture/research-dispatch-controls.md", @@ -2598,8 +2618,8 @@ }, { "path": "scripts/hypothesis_v3_metrics.py", - "sha256": "a574d983b96f3b1d59705bf81ba87e1b1a868b06ff63c9fb234f478b3538f73f", - "bytes": 4257 + "sha256": "3dc059322a96dcf87eef33374250942698f146a3fda76732e301f76cc7e532ad", + "bytes": 11873 }, { "path": "scripts/measure_project_lists.py", @@ -2638,8 +2658,8 @@ }, { "path": "scripts/prepare_synthetic_radar_demo.py", - "sha256": "6bd1b826d5f082231a8b2f51568a8426a92cba466d59b1498754dacf93adab86", - "bytes": 16228 + "sha256": "551278e1fd6fe950829297d41c31dfe523e6fd715d6a1d947f18d7cca8cb5fed", + "bytes": 18794 }, { "path": "scripts/probe_source_files.py", @@ -2723,8 +2743,8 @@ }, { "path": "scripts/update_source_manifest.py", - "sha256": "56d55a84f9b02a6357efc135d4335f0aa8fa7a67af2b65e2b41e984e70db9d07", - "bytes": 5635 + "sha256": "c2bfefe335b9ae11cf04e52aa7b2e7393a1b059f225f02c9d8f51551a4be9756", + "bytes": 9632 }, { "path": "scripts/validate_field_kit.py", @@ -3103,13 +3123,13 @@ }, { "path": "src/thoth/adapters/models/codex_http.py", - "sha256": "c312739f9f47b7bcff520d0c6df7fca4f0734d396eddffdb7cb14df3a8cb9dac", - "bytes": 16693 + "sha256": "ea1eec498ac53c8d7076ad8ef543b5dd89d11d7b23303ab86c6a1a5f1712ee7f", + "bytes": 16754 }, { "path": "src/thoth/adapters/models/codex_oauth.py", - "sha256": "b6b3f8e25ebe72dba61efeda446e15dbb0cde4ec53e73009be36c557bea1ed1b", - "bytes": 25255 + "sha256": "fd0dbe36302edfd26866259a105290bb5408165d114fd7b798ff65a36dfb33c1", + "bytes": 25725 }, { "path": "src/thoth/adapters/models/codex_profile.py", @@ -3133,8 +3153,8 @@ }, { "path": "src/thoth/adapters/models/hypothesis_contract_text.py", - "sha256": "db73fcf68d6a26150e7f2ca2ae13ba9736b09e6f0e502cf043a60f2d9e4ba771", - "bytes": 1517 + "sha256": "149262ad513e00f3d59a2970a5d4d830805501bbb0e8dd9a512c0b8a5444e45b", + "bytes": 9957 }, { "path": "src/thoth/adapters/models/local_credentials.py", @@ -3148,8 +3168,8 @@ }, { "path": "src/thoth/adapters/models/receive_stats.py", - "sha256": "f7c40959062a97bac619d97414df7161267c18d327617bb2b1b282722138c3b7", - "bytes": 5469 + "sha256": "73aa11c07a33fd7e30f189a273e7362efdebde6ccf057a33c56aa3d0dab6b1b1", + "bytes": 5599 }, { "path": "src/thoth/adapters/models/reference_schema.py", @@ -3188,8 +3208,13 @@ }, { "path": "src/thoth/adapters/models/transport_diagnostic.py", - "sha256": "d739b87b452b734a84dc6d844baa3ae83d97e12600b1c979b5821e91872bbc02", - "bytes": 4248 + "sha256": "5b823436294e56900fb487bee13c819e84e6336d42da0a592f64698700f48e21", + "bytes": 5194 + }, + { + "path": "src/thoth/adapters/models/transport_message.py", + "sha256": "4b8c33cd8e6b85b85ed0f02dc61c1457972784d96a2448779f9404aee8398b4e", + "bytes": 3721 }, { "path": "src/thoth/adapters/models/windows_process_job.py", @@ -3243,8 +3268,8 @@ }, { "path": "src/thoth/adapters/parsers/csv_parser.py", - "sha256": "562f64aa112c1b4bffa5ed4a1d89a61a71156e7dfa10c7427494a28ad7d51ac3", - "bytes": 1584 + "sha256": "28405f870c9995caf799062a8d18eef59a0302170893b7204fb58901e9ff6673", + "bytes": 3480 }, { "path": "src/thoth/adapters/parsers/docling_locations.py", @@ -3318,8 +3343,8 @@ }, { "path": "src/thoth/adapters/parsers/text_parser.py", - "sha256": "bad3ccb07b9149021931bc1e51c54eb5f2a174135390f07e76a3992dd525d521", - "bytes": 1780 + "sha256": "df687b7f53b5adb20ff51a6d49189672a89658f26cfb15b981533cf8e527e708", + "bytes": 3530 }, { "path": "src/thoth/adapters/parsers/xlsx_parser.py", @@ -4013,8 +4038,8 @@ }, { "path": "src/thoth/application/services/acquisition_coordinator.py", - "sha256": "768d22f564d72547b6bb6f28a159e7284c95421f831610bbf150ff6f9e627527", - "bytes": 14187 + "sha256": "578d1957373f7f35f402aaadc1ce002b964dee4057440886d0f51558362d9d8f", + "bytes": 15312 }, { "path": "src/thoth/application/services/action_compiler.py", @@ -4151,6 +4176,11 @@ "sha256": "43d22e37e8a5eac8a0431a2cc08052f1ed984d693c8a91d244664d0e3691a8e4", "bytes": 14872 }, + { + "path": "src/thoth/application/services/counter_search_calculations.py", + "sha256": "76aa145503b8fb46954173f44a26e60ff2559d761b91597f3bc2d5f82fe316cb", + "bytes": 3675 + }, { "path": "src/thoth/application/services/criterion_compiler.py", "sha256": "7fd5eaac2e3fc9d091353e7378c4da81343ba833a05bea9d945bb73ecef1c4c6", @@ -4178,8 +4208,8 @@ }, { "path": "src/thoth/application/services/critical_counter_search_coordinator.py", - "sha256": "3e2cb2e22520addd40a0a725858c8ae40d344e5c4188de0fe68703942d967256", - "bytes": 36327 + "sha256": "0412ed1c7c0dffd9e759fcac448b61bae37297090b6edbb4a6fb6b4700ab3497", + "bytes": 34467 }, { "path": "src/thoth/application/services/critical_counter_search_finalization.py", @@ -4318,8 +4348,8 @@ }, { "path": "src/thoth/application/services/hypothesis_contract_v3.py", - "sha256": "431c17f9b5d48cea5957dd39ef3668a2b64a9624fdd8dbf9bf36424d95a97318", - "bytes": 4776 + "sha256": "58b0d3665765d948a2a8313e9c7aaaa3c3b4990f7f995a93eb52bf1c14a3951b", + "bytes": 12762 }, { "path": "src/thoth/application/services/hypothesis_link_recheck.py", @@ -4333,8 +4363,8 @@ }, { "path": "src/thoth/application/services/hypothesis_projection.py", - "sha256": "fc6008fd9d4973bd20fb4fadf35a4f614058e4f5fef57c16cc579daa7e7c4f07", - "bytes": 12020 + "sha256": "ec5b066fc8128b8b329aeee2b7ba5eef733ca617697a1699fc90eb5ae27c66b6", + "bytes": 12186 }, { "path": "src/thoth/application/services/hypothesis_same.py", @@ -4346,6 +4376,11 @@ "sha256": "9ecedf995c78c8b7c271f008385a6b53823a1b03a33dbe73566701f01d68e0fa", "bytes": 35440 }, + { + "path": "src/thoth/application/services/hypothesis_table_filler.py", + "sha256": "e14e9d46d3b849875cc5d6e88d43890f8d4248f7b866437faaa1f343bc427335", + "bytes": 15382 + }, { "path": "src/thoth/application/services/hypothesis_test_lifecycle.py", "sha256": "db0df0b9a151159fbf4d9c3d8bbde40c7d190888e6005a988fb2aa7fe8f69e4e", @@ -4428,7 +4463,7 @@ }, { "path": "src/thoth/application/services/memory_query_expansion.py", - "sha256": "cceae7a585e871f10f1ec8f0c2520c8934f5b168b8224450486897cb07d9a519", + "sha256": "7130cc86f136a5d7dc294dd40d67c8213ffd722cec02c9d610ee1654c520e9ec", "bytes": 6093 }, { @@ -4443,7 +4478,7 @@ }, { "path": "src/thoth/application/services/memory_relation_judge.py", - "sha256": "3a6e237735588da950b248b01b096b9cd9dd414a4bc716838f9cc197e1497deb", + "sha256": "5986c58f9ed213443557a213054a671357e1cf605450b1aae0aadb6da114617e", "bytes": 2794 }, { @@ -4618,8 +4653,8 @@ }, { "path": "src/thoth/application/services/research_analysis.py", - "sha256": "afeaf7a6bc9b37fda795acc03568c96fbe649848de0b5ab66017305de519f8b6", - "bytes": 38372 + "sha256": "e52944ce36bf07b1c89896fef8b54940cb253a9a35700685f702d3bfabe31a13", + "bytes": 38308 }, { "path": "src/thoth/application/services/research_attempt_admission.py", @@ -4628,8 +4663,8 @@ }, { "path": "src/thoth/application/services/research_attempt_runner.py", - "sha256": "3a91ba468a650a9176eaa3045bd2ec5588339a8ca0f3143760dead5896a8f509", - "bytes": 18597 + "sha256": "00f240fe2cde75ec72c469c462eaa756d00dd7293fab79760d86a24051774b6c", + "bytes": 18658 }, { "path": "src/thoth/application/services/research_basis_capture.py", @@ -4773,8 +4808,8 @@ }, { "path": "src/thoth/application/services/research_retrieval.py", - "sha256": "b29b702726452d1c7d9a6c2eeedd4541c318492b8b76e572a17bcaf7dae4f409", - "bytes": 6167 + "sha256": "bd9d7e6e58aacba8fec9c6f875bc6788393f01e44a42386cda2aaf1c096c27e2", + "bytes": 7915 }, { "path": "src/thoth/application/services/research_retrieval_policy.py", @@ -4961,6 +4996,11 @@ "sha256": "6610c70c17fc3f58b0bfb0b33149762c587f97e35b51b8d998f08202bc995b47", "bytes": 13251 }, + { + "path": "src/thoth/application/services/text_script.py", + "sha256": "18f1542fda7b95ea4601806cc3c42021b2351a184f30d820c443e696c3ff0b91", + "bytes": 2751 + }, { "path": "src/thoth/application/services/trace_closure.py", "sha256": "b00d0aa97577502594f4f7204a20895bbf2193ede1418fc6ef439cfaa926d8d8", @@ -4978,8 +5018,8 @@ }, { "path": "src/thoth/application/services/trace_origin.py", - "sha256": "20adc502d08972447b2b547a20004bb9836790ef397e17853eb3c7932fef83ef", - "bytes": 5008 + "sha256": "c2c0ff9c4af70186fa356ed2c27a21bc275be6179df9cd05a80ae0db06201a12", + "bytes": 9009 }, { "path": "src/thoth/application/services/tui_session_service.py", @@ -5038,8 +5078,13 @@ }, { "path": "src/thoth/application/workflows/thread_cycle.py", - "sha256": "f17d64a7703f05e3cf0d9e689796bf6b2be7f5433176f6bd976d725b84049ef1", - "bytes": 38736 + "sha256": "3b0bbc7726d45195e411b5f106dd9230ef72346b34c08975fd45a7e6ea15b762", + "bytes": 37191 + }, + { + "path": "src/thoth/application/workflows/thread_cycle_preparation.py", + "sha256": "1617995be69f3bb859c2dc43c58a2783787388ec9e1e47a9b03481c46e7041d4", + "bytes": 1247 }, { "path": "src/thoth/apps/__init__.py", @@ -5398,8 +5443,8 @@ }, { "path": "src/thoth/domain/enums.py", - "sha256": "45711b0fa60b2fd8239aae21220b26a26718b043420018a0bb465302db18dd1d", - "bytes": 10647 + "sha256": "588a05b7ade5620613a61f2c657476d11fb6154cbb077eaa68eecf1a387355a1", + "bytes": 10703 }, { "path": "src/thoth/domain/environment.py", @@ -5488,8 +5533,8 @@ }, { "path": "src/thoth/domain/hypothesis.py", - "sha256": "938699ae94b5fad8c086b411c05166a0ca70fa8fb72a302a3d6c6b993ae474b1", - "bytes": 6021 + "sha256": "87006cd04669f05d437b331319f1ccb529cd523bc7b892b41ca39b92b4aee1f3", + "bytes": 8787 }, { "path": "src/thoth/domain/hypothesis_full.py", @@ -5583,8 +5628,8 @@ }, { "path": "src/thoth/domain/model_call_settings.py", - "sha256": "3771cf166e5e7fd29e7c2b44a5449b8772abbd6b1c347117ef616f8e8a0f9190", - "bytes": 1170 + "sha256": "ce35097577e69f060963892e485975758125b07a017ff85a881fc59836b7d9e5", + "bytes": 1071 }, { "path": "src/thoth/domain/model_catalog.py", @@ -5593,8 +5638,8 @@ }, { "path": "src/thoth/domain/model_dispatch.py", - "sha256": "3f8e1816a4298136308b6d206eff9fa7454c903cb7031ebcd579d35554bac5d2", - "bytes": 8255 + "sha256": "157b754b73560ce9c1af44e55395d1092898a085ff7b2a67b940745763d94418", + "bytes": 8640 }, { "path": "src/thoth/domain/model_settings.py", @@ -5703,8 +5748,8 @@ }, { "path": "src/thoth/domain/research_execution.py", - "sha256": "f210c775f3d5f1e750bde15729159a45d65cc7cc49725389983949545b001460", - "bytes": 4849 + "sha256": "c3d6a927e30875248e8e6a45e6985bfb9986d3e6504d44afd920045e67a44a65", + "bytes": 5100 }, { "path": "src/thoth/domain/research_failure.py", @@ -5733,8 +5778,8 @@ }, { "path": "src/thoth/domain/research_projection.py", - "sha256": "19a0c40e82a651446438b0d1c230c23c7ce1e0f6307f8b4d99802c9d40f25805", - "bytes": 3311 + "sha256": "fbb581524095e55fda42d479434f079e8112f8eba71b591217ebbd4d1bf1efcf", + "bytes": 3684 }, { "path": "src/thoth/domain/research_queue.py", @@ -5813,8 +5858,8 @@ }, { "path": "src/thoth/domain/trace_origin.py", - "sha256": "2d9d0e00e20ab7bbc3b9a914532a59ff3a9db75e0d005981f093c6b6a75a8e88", - "bytes": 2161 + "sha256": "922e6f06c18a949de8338922715ef3ff93c65f098f67c408ddcb644cab35c9e6", + "bytes": 3456 }, { "path": "src/thoth/domain/upload_scope.py", @@ -6248,8 +6293,8 @@ }, { "path": "tests/architecture/public_source_profile.py", - "sha256": "d52efb4c29df3dcad80376e2a29ceead35c062e2a75d9bea4341be06831e46b8", - "bytes": 8272 + "sha256": "7b58d2befde179092d74bf274ff084aef4555c4de0e498d081ae09bdfbb899df", + "bytes": 8701 }, { "path": "tests/architecture/rule_transaction_helpers.py", @@ -6363,8 +6408,8 @@ }, { "path": "tests/architecture/test_public_source_profile.py", - "sha256": "ee865e0f8f07f1d5be7015b051829783f0853c0a983520a544b19aab5b7b5054", - "bytes": 12697 + "sha256": "be02aa75ed9fb37fea9b9940a7d8d134bd830e5b6afbbef7a85c3238ce269267", + "bytes": 12711 }, { "path": "tests/architecture/test_read_only_command_contract.py", @@ -7116,6 +7161,11 @@ "sha256": "16fb26b6bd144b640e3436591dabcf65a6925c7c7db5697869c76398badd0d9c", "bytes": 10306 }, + { + "path": "tests/integration/test_acquisition_io_transaction_boundary.py", + "sha256": "08274918b2c74e79dcc23f944713cd3f66412f929b207db18933f3edcfc4b311", + "bytes": 2581 + }, { "path": "tests/integration/test_action_effect_value_type.py", "sha256": "5f5832c738f49e6e46a1f2233d14d6414aedf2a7c2ddf2e52e7078fd7269859e", @@ -7266,6 +7316,11 @@ "sha256": "c16c3b894c77dd0ee782e1938acd8f55a3545d41fe197ea3656779e45674a995", "bytes": 6720 }, + { + "path": "tests/integration/test_evidence_coverage.py", + "sha256": "90068cf9fc91c76ad2462c29cbd6e913c8fda14e9c8435771985d56580d9c643", + "bytes": 8940 + }, { "path": "tests/integration/test_evidence_full_rpc.py", "sha256": "8b13a3bde7415d9d365465cb19169065da2ad79e5733580a6c13621f7b8f4507", @@ -7343,8 +7398,8 @@ }, { "path": "tests/integration/test_hypothesis_contract_v3_run.py", - "sha256": "50aa3fccf47be9e9dbf9cf9cbf6d75bc23fdc9f98d5e3d91bba05fce72755019", - "bytes": 9833 + "sha256": "b8d2cdd96502289242aab2ab9cba73cfd70d61e5c76fd377cde681affc4db2a1", + "bytes": 17533 }, { "path": "tests/integration/test_hypothesis_full_rpc.py", @@ -7453,8 +7508,8 @@ }, { "path": "tests/integration/test_model_call_settings.py", - "sha256": "47cea6cad58a2d9a65cbe814211ba465520d3c7c801d00e032218f8fc1f1b825", - "bytes": 4451 + "sha256": "f1fec431b8a9b0ab7e7c370e30a39ccbe2f0ed0ca61c779d34f79734cf2a4020", + "bytes": 8862 }, { "path": "tests/integration/test_model_catalog_refresh_rpc.py", @@ -7563,8 +7618,8 @@ }, { "path": "tests/integration/test_prepare_synthetic_radar_demo.py", - "sha256": "77ed779b04693763fc2aa2f507b973550061b32643882db1dffd12092ce3e305", - "bytes": 7267 + "sha256": "727c9dd658b3bd54f7dcc1d6fb212268173db68ec9b1cc7a309900a1f473a7ad", + "bytes": 7774 }, { "path": "tests/integration/test_profile_cross_domain_overfit.py", @@ -8066,6 +8121,11 @@ "sha256": "5ec8db2d065990cc16e1a5f744c400ac2b957be451038ea6e18ec11e961d2a13", "bytes": 5176 }, + { + "path": "tests/integration/test_thread_cycle_preparation_contract.py", + "sha256": "e3534db2ff4d300f95cf9220605ac191f45e5a14f3b2920fb34bb7352d1508ae", + "bytes": 9793 + }, { "path": "tests/integration/test_thread_full_catalog_rpc.py", "sha256": "e2247840510468a907c75a130cc583cd9bcb8cccd66e3dae2893b4db011fba17", @@ -8101,10 +8161,15 @@ "sha256": "b9e1a7a36946a9b2acb25036b570e3e8dcb2159a347a5799245debc2e64c75de", "bytes": 12302 }, + { + "path": "tests/integration/test_trace_origin_siblings.py", + "sha256": "395ad27dc08b3e73d44b3749b3a00800e48020f5062eb535ee9c36b9d3bc4d5b", + "bytes": 9472 + }, { "path": "tests/integration/test_transport_diagnostic_persistence.py", - "sha256": "9034e8794346a2f781043b9e63d49ab6af546288640928196d576a42018f9dd5", - "bytes": 6284 + "sha256": "d60cfe51a9ede8d1e99bf591e0318715248d2e85c2467ceabeaeed21a0961c97", + "bytes": 6698 }, { "path": "tests/integration/test_tui_auto_context.py", @@ -8256,6 +8321,11 @@ "sha256": "88ffcd40f9471c1d012952f6ff7943e6a8514897494b693a12768fb169dfbe96", "bytes": 6054 }, + { + "path": "tests/unit/application/test_hypothesis_table_filler.py", + "sha256": "5d7bf7adb6ae6b71222bdfd6712696d8e3d6950acb2ee04d185ad7892954a9a4", + "bytes": 22747 + }, { "path": "tests/unit/application/test_object_input_contract.py", "sha256": "e8f3db6cd6a429dd8cafb0ef23da90ae22a22f9370954edda58dc155e73f16bb", @@ -8361,6 +8431,11 @@ "sha256": "8a78047a6f92a78a91cf42f83680b01c600c0905108ca15a3db0df54070cbc3c", "bytes": 1440 }, + { + "path": "tests/unit/domain/test_expected_label_language_settle.py", + "sha256": "d9f7d0a772eab9e4d90158e7b591ccf45e61796b2f5aba263b118d3962fb822e", + "bytes": 3663 + }, { "path": "tests/unit/domain/test_extended_models.py", "sha256": "805be1904ed88b46cb86a92b3cbc8f434eaacc7c542936061553d2d7ac2daf8e", @@ -8423,8 +8498,8 @@ }, { "path": "tests/unit/domain/test_hypothesis_contract_v3.py", - "sha256": "ace3f9b84f7e983855bedab8a882b040677949e121382b84dad57cc4b88c071d", - "bytes": 8699 + "sha256": "d9c46a71a3b0dcff64f307566424584002cf509b66fbb89b6089a9fc36e3b307", + "bytes": 8701 }, { "path": "tests/unit/domain/test_hypothesis_review_validation.py", @@ -8471,6 +8546,11 @@ "sha256": "43c9b45bc7c6ee6b5c93d5d24b8d7fceefa9c26c71230b7b825ff4f398deb08a", "bytes": 1338 }, + { + "path": "tests/unit/domain/test_refutation_language_settle.py", + "sha256": "ee89e70f44d08b998af75b3ef5ab1091134120636bd74732920a36ce8d41ef3c", + "bytes": 7993 + }, { "path": "tests/unit/domain/test_research_failure.py", "sha256": "c0224f40d323e07d57c974ef5a90556ef266aff8d0ff2570891c75af347c1148", @@ -8491,6 +8571,11 @@ "sha256": "6a36765cdeec932673a70b5a687418e9d156516e36afa88d54b652511641b3ad", "bytes": 10008 }, + { + "path": "tests/unit/domain/test_visible_text_language_settle.py", + "sha256": "30841398a72dd4a9d0e742ab8857956834d79788df75dd8fc368ea97ccd4ab01", + "bytes": 10307 + }, { "path": "tests/unit/evaluators/test_evaluator_registry.py", "sha256": "1d3bc615622bdb37e97f0a1d85f6412caefd1b2ffc35079058e918495747ca38", @@ -8628,8 +8713,18 @@ }, { "path": "tests/unit/models/test_hypothesis_contract_wire_text.py", - "sha256": "8d9446b2b587ff771edd870538bb7ba234ec15684a69c42b5dd9434e954f67ee", - "bytes": 3326 + "sha256": "44d5df25e8c2909fa1aceee3164038bba9c9fc9282632be372bae07eef22b5f4", + "bytes": 14082 + }, + { + "path": "tests/unit/models/test_hypothesis_generator_opening.py", + "sha256": "790a680c3af024767534e9ed5d791fb8e3da5ca21fa0e44b5351766dedfd0683", + "bytes": 2678 + }, + { + "path": "tests/unit/models/test_hypothesis_table_filler_wire_text.py", + "sha256": "d7b2c981c29fc07952a737679953e24788cc54f64407393b9169698b25cb66fc", + "bytes": 5623 }, { "path": "tests/unit/models/test_interrupted_call_retry.py", @@ -8708,8 +8803,13 @@ }, { "path": "tests/unit/models/test_transport_diagnostic.py", - "sha256": "64a45abe7ff36353f692ba6e5e088f0eac0b7b3d378afcf63d63a7bac6b1bc1b", - "bytes": 11567 + "sha256": "7711850dc8792010aa85fef2dd9ac62eb7c1c4df0e830a309f88088e0788c327", + "bytes": 11668 + }, + { + "path": "tests/unit/models/test_transport_diagnostic_privacy.py", + "sha256": "8e39b7e548314f771a6545df4cfaaeeb1204b96f0fb828685d148edc50d29132", + "bytes": 7344 }, { "path": "tests/unit/models/test_transport_idle_integration.py", @@ -8721,6 +8821,11 @@ "sha256": "cdfa6f44bf946b4bc47d6416d4f10c4fa456d2cde94cd1e8daf3c103d10e20ad", "bytes": 1305 }, + { + "path": "tests/unit/models/test_user_visible_language_contract.py", + "sha256": "ea3a040a62351a6b938d9c90a51386f887e2944cf2e255227a3440fb34803a8d", + "bytes": 1645 + }, { "path": "tests/unit/models/test_windows_process_job.py", "sha256": "c43376a60aec25945870ee67bcf684776ed55de2fb908aaeb95697c5c9b4947a", @@ -8781,6 +8886,21 @@ "sha256": "6c3fe043f957dfed351a5ba1ec0bdd664e4c244245a9997a1c7e541e155be05b", "bytes": 1752 }, + { + "path": "tests/unit/services/acquisition_coordinator_support.py", + "sha256": "cd8457e7dfb454e8b8bc29b676f21f87d4e60ee2287d161331c4189f3a328f37", + "bytes": 14232 + }, + { + "path": "tests/unit/services/counter_wave_support.py", + "sha256": "1a32f88ada400201ca66b22f34d855a5d3f936b66b5141ce60fc6905a6c2953e", + "bytes": 12810 + }, + { + "path": "tests/unit/services/test_acquisition_coordinator_contract.py", + "sha256": "338a37fa027e163377c60a4ea1e19b192d8c7cbeef637294685ea25c3989bd7a", + "bytes": 8369 + }, { "path": "tests/unit/services/test_action_calculation_contract.py", "sha256": "0f0e07cf891aa93616c315c98af4f2d98154dc24b3c1725a9d20150bb9917b97", @@ -8801,6 +8921,11 @@ "sha256": "b8e38880709329ad4fe4618305b07e7377fe03bc30659b303b333367a3b330b3", "bytes": 4869 }, + { + "path": "tests/unit/services/test_counter_wave_contract.py", + "sha256": "338b8c301b7a072ce847c3b4f79aaf87b53c4497bae93748022116b9a4f54ceb", + "bytes": 8280 + }, { "path": "tests/unit/services/test_criterion_compiler.py", "sha256": "ab7bdbc68f3c75b6c9262657059539ba4ec8b1c820d90fb5ccb2575705c2dd4f", @@ -8901,6 +9026,11 @@ "sha256": "a4785d843974a37b33a9e68aecf38956e3be8078c06762c790b0cf51f46c6b11", "bytes": 4679 }, + { + "path": "tests/unit/test_csv_parser_rows.py", + "sha256": "2ca879d1f89ce7dcf275df33f736dcf1b9f04fe36dae14d7f6f742068dd44937", + "bytes": 4574 + }, { "path": "tests/unit/test_cutoff_precision_sites.py", "sha256": "65c84283d7ade110a42464729691cd24d12163c9eaece840a61da02bf188dc36", @@ -8973,8 +9103,8 @@ }, { "path": "tests/unit/test_hypothesis_v3_metrics.py", - "sha256": "90c08474ff1cd3792348052cf3553b86bc4f45400075f20ce5b6a6f4ae736a4f", - "bytes": 2052 + "sha256": "88e55392895bb63a1da1ad7c26abbee88778d813194bd29779ecb51dea18020f", + "bytes": 10661 }, { "path": "tests/unit/test_lesson_recall_cases.py", @@ -9046,6 +9176,11 @@ "sha256": "783ae777cfdf04c7f7e589f56ad93da51156423061caa1ee472fd08f7e87c98d", "bytes": 6274 }, + { + "path": "tests/unit/test_prepare_demo_manifest_spec.py", + "sha256": "2ce5c40f912b62990dae843e88c3f5cff97ea1e957f9ad02b31d974a4c0f1ab7", + "bytes": 5115 + }, { "path": "tests/unit/test_public_web_policy_reconciliation.py", "sha256": "253c3edb373575a0df148fbea5442ce27f75111fb4f447a18a8e4964a53dcc25", @@ -9096,6 +9231,11 @@ "sha256": "32ed54ccdbad40524b41dfe4c56e9f505b8a42374e42899fb1d60a32635d1b20", "bytes": 1787 }, + { + "path": "tests/unit/test_research_structured_block_context.py", + "sha256": "d82804af37d1119c5151ca25e7472d73af15c6e10f3480ab972d0b39586f9fe3", + "bytes": 5713 + }, { "path": "tests/unit/test_research_usage.py", "sha256": "f729d6f2523f8530179f24d2aa9c5e8b546e8b1cfb54358bbc31580e53e2ee51", @@ -9126,6 +9266,11 @@ "sha256": "6c0f04e6b38768fd8627ab06943f6c98d8732f1b79393437aa6f39bd386fb016", "bytes": 9483 }, + { + "path": "tests/unit/test_source_manifest_git_bytes.py", + "sha256": "2c6a5ebd5f773bef014b0aad9b45bd68e71f42be68ab60730a5017747630620a", + "bytes": 8635 + }, { "path": "tests/unit/test_source_time_consumption.py", "sha256": "77620974a1f4bab8783139f919e5e59d65f4f807f694521073053a8d2a56197b", @@ -9141,10 +9286,15 @@ "sha256": "50b250812a5f294a1ccaac26a6961009346083900be6e0931ad4c3b56f154e56", "bytes": 2987 }, + { + "path": "tests/unit/test_text_parser_key_paths.py", + "sha256": "b04cfb1dc86f3673747802d1c836197dff0c0c3e14b7a8761278db294f37936f", + "bytes": 2905 + }, { "path": "tests/unit/test_trace_origin_context.py", - "sha256": "35d5fa186d212bc2e3553f50b97372c579fba6928cafae496efb3091f52235a2", - "bytes": 3476 + "sha256": "d86caff646bf330a52377dc1f0846aa67838f24a47e1b3fc2e4130422058f354", + "bytes": 3776 }, { "path": "tests/unit/test_tui_presentation.py", diff --git a/apps/web/src/components/AutoRetrySwitch.test.tsx b/apps/web/src/components/AutoRetrySwitch.test.tsx index 3f39a2b..adad045 100644 --- a/apps/web/src/components/AutoRetrySwitch.test.tsx +++ b/apps/web/src/components/AutoRetrySwitch.test.tsx @@ -5,7 +5,8 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vitest"; import { AutoRetrySwitch, autoRetryHelp } from "./AutoRetrySwitch"; -const state = vi.hoisted(() => ({ enabled: false, digest: null as string | null, calls: [] as { method: string; input: Record }[], fail: false })); +// 사용자 결정 20261010-01: a project without saved settings is on. +const state = vi.hoisted(() => ({ enabled: true, digest: null as string | null, calls: [] as { method: string; input: Record }[], fail: false })); vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()), rpc: async (method: string, input: Record) => { state.calls.push({ method, input }); @@ -23,12 +24,13 @@ async function mount() { await act(async () => root!.render()); await settle(); } -afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; state.enabled = false; state.digest = null; state.calls = []; state.fail = false; }); +afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; state.enabled = true; state.digest = null; state.calls = []; state.fail = false; }); -it("starts off as the server says, says what turning it on costs, and sends the digest it read when turned on", async () => { +it("starts on as the server says, explains the cost, and sends the digest it read when turned off", async () => { await mount(); const input = container.querySelector("input[type=checkbox]")!; - expect(input.checked).toBe(false); + // 사용자 결정 20261010-01: show the server's new default and let the user turn it off. + expect(input.checked).toBe(true); expect(container.textContent).toContain("모델 호출이 중간에 끊기면 그 호출만 최대 2번 자동으로 다시 시도합니다. 토큰이 더 쓰입니다."); expect(container.textContent).toContain("끄면 끊긴 결과 카드의 \"이어서 조사\"로 다시 할 수 있습니다."); expect(autoRetryHelp).toContain("토큰이 더 쓰입니다"); @@ -36,8 +38,15 @@ it("starts off as the server says, says what turning it on costs, and sends the await act(async () => input.click()); await settle(); const update = state.calls.find(call => call.method === "model/callSettings/update")!; - expect(update.input).toEqual({ project_id: "project:p", auto_retry_interrupted_model_call: true, expected_digest: null }); - expect(container.querySelector("input[type=checkbox]")!.checked).toBe(true); + expect(update.input).toEqual({ project_id: "project:p", auto_retry_interrupted_model_call: false, expected_digest: null }); + expect(container.querySelector("input[type=checkbox]")!.checked).toBe(false); +}); + +it("keeps an existing saved off choice instead of replacing it with the default", async () => { + state.enabled = false; state.digest = "saved"; + await mount(); + expect(container.querySelector("input[type=checkbox]")!.checked).toBe(false); + expect(state.calls.map(call => call.method)).toEqual(["model/callSettings/read"]); }); it("says so when the setting cannot be read instead of showing it as off", async () => { diff --git a/apps/web/src/components/ContractV3Screens.test.tsx b/apps/web/src/components/ContractV3Screens.test.tsx index 6375c0d..b1df628 100644 --- a/apps/web/src/components/ContractV3Screens.test.tsx +++ b/apps/web/src/components/ContractV3Screens.test.tsx @@ -93,6 +93,13 @@ it("lists what each hypothesis is expected to give for a test, in words, with ho expect(text).not.toMatch(LEAK); }); +it("marks a prediction that rests on no source, and leaves 모름 and sourced predictions as they were", async () => { + await mount(result([test("t1", "시험", rows([0, "나쁘다", ["span:a"]], [1, "같다"], [2, "모름"]))])); + const lines = [...card().querySelectorAll("details.expected-results li")].map(item => item.textContent); + expect(lines).toEqual(["가설 1: 나쁘다 · 근거 1개", "가설 2: 같다 · 근거 없는 예측", "가설 3: 모름"]); + expect(lines.join(" ")).not.toMatch(BANNED); +}); + it("shows nothing of v3 on an old result", async () => { await mount(result([test("t1", "시험")])); expect(card().querySelector(".ai-refutation")).toBeNull(); diff --git a/apps/web/src/components/EvidenceRail.test.tsx b/apps/web/src/components/EvidenceRail.test.tsx new file mode 100644 index 0000000..2aa3ad4 --- /dev/null +++ b/apps/web/src/components/EvidenceRail.test.tsx @@ -0,0 +1,23 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { EvidenceRail } from "./EvidenceRail"; +import type { EvidenceSpan } from "../types"; + +const row = (locator: EvidenceSpan["locator"], text: string): EvidenceSpan => ({ + span_id: "span:1", artifact_id: "artifact:1", source_version_id: "version:1", exact_text: text, + authority_state: "UNCLASSIFIED", cutoff_state: "ELIGIBLE", verification_state: "SCHEMA_VALID", support_state: "EXTRACTED", locator, +}); + +describe("EvidenceRail position of a table row", () => { + it("shows the line and the row range of a CSV row, and keeps older single cells readable", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("row_id=SYN-T-RAIN-03, detected=0"); + expect(html).toContain("line 4 · R4C1:R4C6"); + expect(html).toContain("R6C2"); + expect(html).not.toContain("구조 위치 없음"); + }); +}); + diff --git a/apps/web/src/components/ExpectedResults.tsx b/apps/web/src/components/ExpectedResults.tsx index 6d957c2..e553e5c 100644 --- a/apps/web/src/components/ExpectedResults.tsx +++ b/apps/web/src/components/ExpectedResults.tsx @@ -10,7 +10,9 @@ export function ExpectedResults({ test, rows }: { test: Record;
    {table.map((item, at) => { const index = rows.findIndex(row => row.id === textValue(item.hypothesis_id)); const sources = stringValues(item.basis).length; - return
  • {"가설 " + (index >= 0 ? index + 1 : "?") + ": " + textValue(item.expected) + (sources > 0 ? " · 근거 " + sources + "개" : "")}
  • ; + const expected = textValue(item.expected); + const note = sources > 0 ? " · 근거 " + sources + "개" : expected.trim() === "모름" ? "" : " · 근거 없는 예측"; + return
  • {"가설 " + (index >= 0 ? index + 1 : "?") + ": " + expected + note}
  • ; })}
모델이 낸 제안이며 사람이 확인하기 전에는 미확정입니다. 같은 말은 같은 결과로 봅니다. 확률이나 점수가 아닙니다. ; diff --git a/apps/web/src/components/FirstRunSetup.test.tsx b/apps/web/src/components/FirstRunSetup.test.tsx index 0d12740..c4bc58e 100644 --- a/apps/web/src/components/FirstRunSetup.test.tsx +++ b/apps/web/src/components/FirstRunSetup.test.tsx @@ -311,7 +311,9 @@ it("starts xAI device login on first run and confirms it without changing the mo const check = [...row.querySelectorAll("button")].find(button => button.textContent === "상태 직접 확인")!; await act(async () => check.click()); await flush(); expect(fixture.calls.some(call => call.method === "model/credential/login/status" && call.input.login_id === "login_test_1")).toBe(true); - expect(container.textContent).toContain("실제 모델 실행 성공은 별도 확인이 필요합니다"); + // The row says once that the real run is not confirmed yet (it used to say it in the login box and again in the note). + expect(row.textContent).toContain("xAI 로그인이 확인됐습니다"); + expect(row.textContent?.match(/실행 성공은 별도 확인|(확인|검증)되지 않았/g)).toHaveLength(1); expect(fixture.calls.some(call => call.method === "model/settings/update")).toBe(false); expect(opened).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/LiveProjectWorkspace.tsx b/apps/web/src/components/LiveProjectWorkspace.tsx index 870dd6c..a5e10cb 100644 --- a/apps/web/src/components/LiveProjectWorkspace.tsx +++ b/apps/web/src/components/LiveProjectWorkspace.tsx @@ -17,6 +17,7 @@ import { SourceTimeAdvanced } from "./SourceTimeAdvanced"; import { useCitationGate } from "./SourceCitationGate"; import { cutoffLabel, projectCutoffText } from "../api/sourceTime"; import { FirstRunSetup } from "./FirstRunSetup"; +import { WorkspaceInternetConsent } from "./WorkspaceInternetConsent"; import { workspaceSetupIssue, workspaceSetupIssueText, type WorkspaceSetupIssue } from "./workspaceSetupIssue"; import { AutoRetrySwitch } from "./AutoRetrySwitch"; import { HypothesisContractSwitch } from "./HypothesisContractSwitch"; @@ -99,9 +100,10 @@ type TraceRowRef = { kind: string; id: string }; type RowOrigin = { threadId: string; origin: PendingOrigin }; function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, onUserInput, hosted = false, workspaceId = null, executionReady = true, readSuspended = false, setupIssue = null, - rowOrigin = null, onOriginDone, onInvestigate, traceFocus = null, onOpenTrace }: { + consent, modelConnected, rowOrigin = null, onOriginDone, onInvestigate, traceFocus = null, onOpenTrace }: { project: ProjectSummary; threadId: string; epoch: RefObject; page: Page; onPage: (page: Page) => void; onAdmitted: (threadId: string) => void; onUserInput: () => void; hosted?: boolean; workspaceId?: string | null; executionReady?: boolean; readSuspended?: boolean; setupIssue?: WorkspaceSetupIssue | null; + consent?: string; modelConnected?: boolean; rowOrigin?: RowOrigin | null; onOriginDone?: () => void; onInvestigate?: (item: RowInvestigation) => void; traceFocus?: TraceRowRef | null; onOpenTrace?: (row: TraceRowRef) => void; }) { const storageScope = useMemo(() => hosted ? { mode: "HOSTED" } : { mode: "LOCAL", workspaceId }, [hosted, workspaceId]); @@ -113,6 +115,8 @@ function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, on const started = session.problem.trim() !== "" || session.submit.isPending; useEffect(() => { if (started) onUserInput(); }, [started, onUserInput]); useEffect(() => { if (page === "records" && contentRef.current) contentRef.current.scrollTop = 0; }, [page]); + const [focusConsent,setFocusConsent] = useState(false); + const clearConsentFocus = useCallback(() => setFocusConsent(false), []); const [catalogNamespace,setCatalogNamespace] = useState("all"); const [detail,setDetail] = useState(null); const [sideTab,setSideTab] = useState("files"); @@ -143,7 +147,8 @@ function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, on {queryError&&{queryError.message}} onPage("settings")} hosted={hosted} executionReady={executionReady}/> + onOpenSettings={target=>{setFocusConsent(target==="consent");onPage("settings");}} hosted={hosted} executionReady={executionReady} + consentMissing={!hosted && consent==="UNDECIDED"} modelConnected={modelConnected}/> {panelOpen&&onPage("records")} onManageResources={()=>{closeDetail();onPage("resources");}}/>} : page==="capabilities" && !hosted ? <> : page==="trace" ? : page==="records" ?

모델 기본값

session.clearModel(session.renderedRevision,capturedEpoch)}/> )} + {!hosted && consent !== undefined && }

사용량

보고된 사용 토큰: {status?.usage?.total_tokens?.toLocaleString()??"아직 없음"}{status?.usage?.state==="PARTIAL"?" (부분 관측)":""}

진행 중인 호출의 사용량은 아직 포함되지 않을 수 있습니다.

{!hosted&&<>}

{hosted?"기록과 자료":"기록과 로컬 도구"}

{!hosted&&<>
}
} @@ -372,7 +378,7 @@ export function LiveProjectWorkspace() {
{project&&projectCutoffText(project.cutoff_at)&&기준시점 {projectCutoffText(project.cutoff_at)}}{health.data?.status==="ok"?"서버 연결됨":health.error?"서버 연결 실패":"연결 확인 중"}{!hosted&&!executionReady&&새 연구 준비 필요}{!hosted&&contextStoreFailed&&작업 위치 저장 실패}{missingSelection&&저장된 {missingSelection==="project"?"프로젝트":"작업"} 선택 확인 필요}
setPage(value as Page)}> {project&&
chooseThread(event.target.value)} options={[{value:"",label:"새 작업"},...visibleThreads.map(item=>({value:item.thread_id,label:(item.problem||"제목 없는 작업").slice(0,40)}))]}/>
}
- {project ? client.invalidateQueries()}>client.invalidateQueries()}>{pendingAdmission.current=threadId;setContext(current=>({...current,threadId}));}} rowOrigin={rowOrigin} onOriginDone={()=>setRowOrigin(null)} onInvestigate={investigate} traceFocus={traceFocus} onOpenTrace={openTraceRow}/> : diff --git a/apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx b/apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx new file mode 100644 index 0000000..84e9df9 --- /dev/null +++ b/apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { writeDraft } from "../api/conversation"; +import { saveWorkspaceContext } from "../api/research"; +import { LiveProjectWorkspace } from "./LiveProjectWorkspace"; + +// A LOCAL workspace that already has a project, with the internet consent still empty (made by an older version or by a script). +const WORKSPACE = `workspace:${"a".repeat(32)}`; +const fixture = vi.hoisted(() => ({ consent: "UNDECIDED", modelConnected: true, calls: [] as { method: string; input: Record }[] })); +vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()), + rpc: async (method: string, input: Record) => { + fixture.calls.push({ method, input }); + let value: unknown = {}; + if (method === "workspace/setup/update") fixture.consent = String(input.internet_consent); + if (method === "workspace/setup/read") value = { internet_consent: fixture.consent, internet_grant_id: null }; + if (method === "workspace/ready") { + const decided = fixture.consent !== "UNDECIDED"; + value = { ready: decided && fixture.modelConnected, execution_ready: decided && fixture.modelConnected, workspace_readable: true, + setup_complete: decided, workspace_id: WORKSPACE, deployment_mode: "LOCAL", model_connected: fixture.modelConnected, + setup: { internet_consent: fixture.consent } }; + } + if (method === "project/list") value = { projects: [{ project_id: "p", name: "old project", overlay: "test", lifecycle: "ACTIVE", revision: 1, cutoff_at: "2026-09-26T00:00:00Z" }] }; + if (method === "thread/list") value = { threads: [{ project_id: input.project_id, thread_id: "t", updated_at: "2026-09-20T00:00:00Z", problem: "stored work", execution_state: "IDLE", lifecycle: "OPEN" }] }; + if (method === "thread/read") value = { project_id: input.project_id, thread_id: input.thread_id, problem: "stored work", lifecycle: "OPEN", execution_state: "IDLE", current_object_ids: [], operation_state: "SUCCEEDED", current_result: null }; + if (method === "thread/activity/list") value = { conversation: { turns: [], next_before_epoch: null, history_limited: false } }; + if (method === "project/source/list") value = { artifacts: [] }; + if (method === "evidence/list") value = { evidence: [] }; + if (method === "model/settings/read") value = { settings_digest: null, selection: { provider: null, model: null, reasoning_effort: null }, effective_settings: null, availability: "UNAVAILABLE", reason_code: "MODEL_CAPABILITY_UNKNOWN", model_options: [] }; + if (method === "model/credential/list") value = { accounts: [] }; + return { value, state: "SUCCEEDED", operation_id: "op" }; + }, +})); + +let root: Root | undefined; +let container: HTMLDivElement; +let client: QueryClient; +async function settle(check: () => boolean) { + for (let i = 0; i < 30; i++) { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 15)); }); + if (check()) return; + } + throw new Error(`did not settle: ${container.textContent?.slice(0, 260)}`); +} +const button = (label: string) => [...container.querySelectorAll("button")].find(item => item.textContent === label) as HTMLButtonElement | undefined; +async function mount() { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ status: "ok", version: "synthetic" })))); + saveWorkspaceContext("p", "t", { mode: "LOCAL", workspaceId: WORKSPACE }); + writeDraft("p", "t", "a question to send", { mode: "LOCAL", workspaceId: WORKSPACE }); + container = document.createElement("div"); document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + await act(async () => root!.render()); + await settle(() => Boolean(container.querySelector("#live-problem"))); +} +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; client?.clear(); container?.remove(); vi.restoreAllMocks(); localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals(); + fixture.consent = "UNDECIDED"; fixture.modelConnected = true; fixture.calls = []; +}); + +it("names the empty internet consent as the reason, and the settings page lets the user choose it", async () => { + await mount(); + const submit = () => container.querySelector('[aria-label="지시 추가"]') as HTMLButtonElement; + expect(container.textContent).toContain("인터넷 사용 동의를 고르세요"); + expect(container.textContent).not.toContain("모델 연결과 초기 설정을 확인하세요"); + expect(submit().disabled).toBe(true); + await act(async () => button("동의 고르기")!.click()); + await settle(() => Boolean(container.querySelector("#workspace-internet-consent"))); + const card = container.querySelector("#workspace-internet-consent") as HTMLElement; + expect(card.textContent).toContain("공개 웹 자료 조회"); + expect(card.textContent).toContain("아직 고르지 않았습니다"); + expect(fixture.calls.some(call => call.method === "workspace/setup/update")).toBe(false); + await act(async () => button("허용")!.click()); + await settle(() => card.textContent?.includes("허용함") === true); + const update = fixture.calls.filter(call => call.method === "workspace/setup/update"); + expect(update).toEqual([{ method: "workspace/setup/update", input: { internet_consent: "ALLOWED" } }]); + await act(async () => ([...container.querySelectorAll('[role="tab"]')].find(tab => tab.textContent === "대화") as HTMLElement).click()); + await settle(() => submit() !== null && !container.textContent?.includes("인터넷 사용 동의를 고르세요")); + await settle(() => !submit().disabled); + expect(container.textContent).not.toContain("새 연구를 실행하려면"); +}); + +it("lets the user deny it from the settings page too, and keeps both choices visible", async () => { + await mount(); + await act(async () => button("동의 고르기")!.click()); + await settle(() => Boolean(container.querySelector("#workspace-internet-consent"))); + const card = container.querySelector("#workspace-internet-consent") as HTMLElement; + expect([...card.querySelectorAll("button")].map(item => item.textContent)).toEqual(["허용", "거부"]); + await act(async () => button("거부")!.click()); + await settle(() => card.textContent?.includes("거부함") === true); + expect(fixture.calls.filter(call => call.method === "workspace/setup/update")[0].input).toEqual({ internet_consent: "DENIED" }); + expect([...card.querySelectorAll("button")].map(item => item.textContent)).toEqual(["허용", "거부"]); +}); + +it("says both when the model is not connected either, and keeps the old words when only the model is missing", async () => { + fixture.modelConnected = false; + await mount(); + expect(container.textContent).toContain("모델 연결과 인터넷 사용 동의를 확인하세요"); + await act(async () => root!.unmount()); + container.remove(); client.clear(); localStorage.clear(); sessionStorage.clear(); + fixture.consent = "DENIED"; + await mount(); + expect(container.textContent).toContain("새 연구를 실행하려면 모델 연결을 확인하세요"); + expect(container.textContent).not.toContain("인터넷 사용 동의를 고르세요"); + expect(button("모델 연결 확인")).toBeDefined(); +}); diff --git a/apps/web/src/components/ResearchComposer.tsx b/apps/web/src/components/ResearchComposer.tsx index b472c85..49217fe 100644 --- a/apps/web/src/components/ResearchComposer.tsx +++ b/apps/web/src/components/ResearchComposer.tsx @@ -4,9 +4,9 @@ import { ResearchProgress, UsageSummary } from "./ResearchProgress"; import { useCitationGate } from "./SourceCitationGate"; import type { useResearchSession } from "./useResearchSession"; -export function ResearchComposer({session,projectId,threadId,epoch,onAttach,onOpenSettings,hosted=false,executionReady=true,readSuspended=false}: { - session:ReturnType;projectId:string;threadId:string;epoch:number;onAttach:()=>void;onOpenSettings?:()=>void; - hosted?:boolean;executionReady?:boolean;readSuspended?:boolean; +export function ResearchComposer({session,projectId,threadId,epoch,onAttach,onOpenSettings,hosted=false,executionReady=true,readSuspended=false,consentMissing=false,modelConnected}: { + session:ReturnType;projectId:string;threadId:string;epoch:number;onAttach:()=>void;onOpenSettings?:(target?:"consent")=>void; + hosted?:boolean;executionReady?:boolean;readSuspended?:boolean;consentMissing?:boolean;modelConnected?:boolean; }) { const citation=useCitationGate(projectId); const submit = async () => { @@ -27,8 +27,10 @@ export function ResearchComposer({session,projectId,threadId,epoch,onAttach,onOp {!hosted && session.pendingRead.kind === "INVALID" && 이전 요청의 복구 정보를 확인하지 못했습니다. 원 작업을 확인하기 전에는 새 key로 제출하지 않습니다.} {!hosted && session.pendingStorageError && {session.pendingStorageError}} {readSuspended && 접근 범위를 다시 확인하는 중입니다. 초안은 계속 쓸 수 있으며 확인이 끝나기 전에는 제출하지 않습니다.} - {!readSuspended && !hosted && !executionReady && 저장된 연구는 계속 볼 수 있습니다. 새 연구를 실행하려면 모델 연결과 초기 설정을 확인하세요. - {onOpenSettings && }} + {!readSuspended && !hosted && !executionReady && 저장된 연구는 계속 볼 수 있습니다. {blockedReason(consentMissing,modelConnected)} + {onOpenSettings && (consentMissing && modelConnected!==false + ? + : )}} {!hosted && session.draftRestoreIssue && {session.draftRestoreIssue === "CORRUPT" ? "이전 초안 저장 기록을 읽지 못했습니다. 기록을 자동 삭제하지 않았으며 새 초안을 저장하면 대체될 수 있습니다." : "이 브라우저의 초안 저장 기록을 읽지 못했습니다. 브라우저 저장소와 작업 공간 식별자를 확인하세요."} @@ -51,3 +53,9 @@ export function ResearchComposer({session,projectId,threadId,epoch,onAttach,onOp {!readSuspended&&} ; } + +/** What actually blocks a new research: an empty internet consent, the model, both, or something the screen cannot name. */ +function blockedReason(consentMissing: boolean, modelConnected: boolean | undefined): string { + if (consentMissing) return modelConnected === false ? "새 연구를 실행하려면 모델 연결과 인터넷 사용 동의를 확인하세요." : "새 연구를 실행하려면 인터넷 사용 동의를 고르세요."; + return modelConnected === false ? "새 연구를 실행하려면 모델 연결을 확인하세요." : "새 연구를 실행하려면 모델 연결과 초기 설정을 확인하세요."; +} diff --git a/apps/web/src/components/TracePage.test.tsx b/apps/web/src/components/TracePage.test.tsx index c5dccac..a54268d 100644 --- a/apps/web/src/components/TracePage.test.tsx +++ b/apps/web/src/components/TracePage.test.tsx @@ -72,6 +72,16 @@ it("shows the first-phase data with rain and fog held, dry passing, and no inter expect(page()).not.toContain("엑셀 대체"); }); +it("does not label SYN-prefixed IDs as synthetic without an explicit notice or marker", async () => { + const view = { ...structuredClone(demo.phase1), items: demo.phase1.items.map(item => ({ ...item, fields: {} })) }; + expect(view.items.every(item => item.item_id.startsWith("SYN-"))).toBe(true); + expect(view.items.every(item => !item.title.includes("SYNTHETIC DEMO DATA"))).toBe(true); + state.view = view as Json; + await mount(); + expect(rowOf("Detection rate in rain").textContent).toContain("보류 · 결과 없음"); + expect(container.querySelector(".trace-synthetic-banner")).toBeNull(); +}); + it("is a labelled table whose rows open and close from a named button", async () => { await mount(); expect(container.querySelector("table caption")?.textContent).toContain("판정"); @@ -116,7 +126,7 @@ it("previews the rain results, applies them, and then only the rain lines fail w expect(rowOf("Synthetic radar detects").textContent).toContain("기준 미달 · 결과 없는 조건도 있음"); expect(rowOf("Detection rate in dry weather").textContent).toBe(dryBefore); expect(rowOf("Detection rate in fog").textContent).toContain("보류 · 결과 없음"); - expect(rowOf("Detection rate in rain").textContent).toContain("측정값 0.80이(가) 기준 0.90 이상(ratio)에 못 미칩니다."); + expect(rowOf("Detection rate in rain").textContent).toContain("측정값 0.80이 기준 0.90 이상(ratio)에 못 미칩니다."); const notice = container.querySelector(".trace-notice")!.textContent!; expect(notice).toContain("반영했습니다."); expect(notice).toContain("보류 · 결과 없음 → 기준 미달"); await click(buttonByLabel("Detection rate in rain 판정 이력 펼치기", container)); diff --git a/apps/web/src/components/WorkspaceInternetConsent.tsx b/apps/web/src/components/WorkspaceInternetConsent.tsx new file mode 100644 index 0000000..eabec9b --- /dev/null +++ b/apps/web/src/components/WorkspaceInternetConsent.tsx @@ -0,0 +1,36 @@ +import { Button, Callout } from "@blueprintjs/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { rpc } from "../api/rpcClient"; + +const STATE_TEXT: Record = { ALLOWED: "허용함", DENIED: "거부함" }; + +/** The workspace's "public web lookup" consent in the settings page: the same choice and the same RPC as the second + * step of the first setup, for a workspace that already has projects and no consent saved. */ +export function WorkspaceInternetConsent({ consent, focus = false, onFocused }: { consent: string; focus?: boolean; onFocused?: () => void }) { + const client = useQueryClient(); + const card = useRef(null); + const update = useMutation({ + mutationFn: (internet_consent: string) => rpc("workspace/setup/update", { internet_consent }, crypto.randomUUID()), + onSuccess: async () => { + await Promise.all([client.invalidateQueries({ queryKey: ["workspace-ready"] }), client.invalidateQueries({ queryKey: ["workspace-setup"] })]); + }, + }); + useEffect(() => { + if (!focus) return; + card.current?.scrollIntoView?.({ block: "center" }); + card.current?.querySelector("button")?.focus(); + onFocused?.(); + }, [focus, onFocused]); + return ; +} diff --git a/apps/web/src/components/XaiDeviceLogin.tsx b/apps/web/src/components/XaiDeviceLogin.tsx index 8ade25c..de9096f 100644 --- a/apps/web/src/components/XaiDeviceLogin.tsx +++ b/apps/web/src/components/XaiDeviceLogin.tsx @@ -59,7 +59,7 @@ function stateMessage(state: LoginState): string { switch (state) { case "PENDING": return "xAI 승인을 기다리고 있습니다. 로그인 시작만으로 연결된 것은 아닙니다."; case "SLOW_DOWN": return "xAI 승인을 기다리고 있습니다. 상태 확인 간격을 늘렸습니다."; - case "CONNECTED": return "xAI 로그인이 확인됐습니다. 사용할 모델을 직접 선택하세요. 실제 모델 실행 성공은 별도 확인이 필요합니다."; + case "CONNECTED": return "xAI 로그인이 확인됐습니다. 사용할 모델을 직접 선택하세요."; case "DENIED": return "xAI 로그인이 거부됐습니다. 다시 시작하려면 새 코드를 요청하세요."; case "EXPIRED": return "기기 코드의 유효 시간이 끝났습니다. 다시 시작하려면 새 코드를 요청하세요."; case "CANCELLED": return "이 로그인 요청을 취소했습니다. 기존 API 키 연결은 유지됩니다."; diff --git a/apps/web/src/components/modelCredentialPresentation.ts b/apps/web/src/components/modelCredentialPresentation.ts index fb54a2e..e9d5046 100644 --- a/apps/web/src/components/modelCredentialPresentation.ts +++ b/apps/web/src/components/modelCredentialPresentation.ts @@ -141,6 +141,26 @@ export function guidanceText(value: string | null | undefined): string | null { return /[가-힣]/.test(value) ? value : null; } +// The server sends two hints that are really this one statement (the real run is not confirmed yet). +const GUIDANCE_THAT_IS_THE_STATUS = new Set([ + "The Codex route is eligible; live execution has not been verified", + "Use the THOTH workspace xAI device login; remote execution is unverified", +]); + +/** The one sentence that says a real model run is not confirmed yet, or null when nothing is open. The screen + * used to join every source (state, flags, guidance, the experimental note) so the same meaning came 5 times. */ +function executionStatusNote(account: CredentialAccount): string | null { + if (credentialAuthMethod(account, "claude_pkce")) return "실제 제3자 사용 허용과 모델 실행 성공은 아직 확인되지 않았습니다."; + if (credentialAuthMethod(account, "claude_code_login")) return "실제 모델 실행 성공은 첫 연구에서 확인합니다."; + if (account.connection_state === "EXECUTION_UNVERIFIED") return account.execution_eligible === true + ? "로컬 실행 시도 조건은 확인됐지만 실제 공급자 성공은 아직 검증되지 않았습니다." + : "새 모델 실행 시도 조건이 아직 확인되지 않았습니다."; + if (!account.connected) return null; + if (account.execution_verified === false) return "실제 모델 실행 성공은 아직 확인되지 않았습니다."; + if (account.remote_auth_verified !== true) return "실제 공급자 호출은 아직 확인되지 않았습니다."; + return null; +} + export function credentialAvailabilityNote(account?: CredentialAccount): string | null { if (!account) return null; const notes: string[] = []; @@ -148,26 +168,24 @@ export function credentialAvailabilityNote(account?: CredentialAccount): string ? "THOTH 전용 별도 프로필을 사용하며 기존 Codex Desktop 인증 파일을 가져오지 않습니다." : "THOTH 전용 별도 프로필을 사용합니다."); if (account.profile_mode === "THOTH_XAI_OAUTH") notes.push("xAI 로그인은 이 THOTH 작업 공간의 별도 인증을 사용합니다. API 키 연결은 유지됩니다."); - if (loginSupported(account) && !xaiDeviceLoginSupported(account)) notes.push("Codex 모델 연결은 실험적이며 실행 성공은 별도 확인이 필요합니다."); - if (xaiDeviceLoginSupported(account)) notes.push("xAI 로그인 확인과 실제 모델 실행 성공은 별도입니다."); - if (credentialAuthMethod(account, "claude_code_login")) notes.push("Claude 로그인은 공식 Claude Code가 THOTH 전용 프로필에서 처리합니다. 실제 모델 실행 성공은 첫 연구에서 확인합니다."); - if (credentialAuthMethod(account, "claude_pkce")) notes.push("Claude OAuth는 사용자가 명시적으로 선택하는 별도 경로입니다. 실제 제3자 사용 허용과 모델 실행 성공은 아직 확인되지 않았습니다."); + if (loginSupported(account) && !xaiDeviceLoginSupported(account)) notes.push("Codex 모델 연결은 실험적입니다."); + if (credentialAuthMethod(account, "claude_code_login")) notes.push("Claude 로그인은 공식 Claude Code가 THOTH 전용 프로필에서 처리합니다."); + if (credentialAuthMethod(account, "claude_pkce")) notes.push("Claude OAuth는 사용자가 명시적으로 선택하는 별도 경로입니다."); if (account.connection_state === "LOGIN_REQUIRED") notes.push("로그인을 완료한 뒤 연결 상태를 다시 확인하세요."); if (account.connection_state === "LOGIN_PENDING") notes.push("로그인 절차를 마친 뒤 연결 상태를 다시 확인하세요. 시작 응답은 연결 완료가 아닙니다."); if (loginUnavailable(account)) notes.push("실행 파일의 설치·지원 버전을 확인하세요. THOTH가 자동 설치하거나 기존 Desktop 인증을 변경하지 않습니다."); if (account.connection_state === "CATALOG_UNAVAILABLE") notes.push("로그인과 별개로 사용할 모델 목록을 확인하지 못했습니다."); - if (account.connection_state === "EXECUTION_UNVERIFIED") notes.push(account.execution_eligible === true - ? "로컬 실행 시도 조건은 확인됐지만 실제 공급자 성공은 아직 검증되지 않았습니다." - : "새 모델 실행 시도 조건이 아직 확인되지 않았습니다."); if (account.connection_state === "UNAVAILABLE" && account.reason_code) notes.push(`연결 경로를 사용할 수 없습니다 (${account.reason_code}).`); const guidance = guidanceText(account.guidance); - if (guidance) notes.push(guidance); - if (!account.connected) return notes.join(" ") || null; + // A hint that only says the run is not confirmed is the status itself: it is shown once, in the status place. + const guidanceIsStatus = account.guidance != null && GUIDANCE_THAT_IS_THE_STATUS.has(account.guidance); + if (guidance && !guidanceIsStatus) notes.push(guidance); + const status = guidanceIsStatus ? guidance : executionStatusNote(account); + if (!account.connected) return [...notes, ...(status ? [status] : [])].join(" ") || null; if (!credentialLocallyConfigured(account) && !account.has_key) notes.push("THOTH에서 사용할 수 있는 연결 경로를 확인하지 못했습니다."); const routes = account.available_model_providers; if (routes?.length === 0) notes.push("현재 선택 가능한 모델 경로가 없습니다."); - if (account.execution_verified === false) notes.push("실제 모델 실행 성공은 아직 확인되지 않았습니다."); - if (account.remote_auth_verified !== true) notes.push("실제 공급자 호출은 아직 확인되지 않았습니다."); + if (status) notes.push(status); return notes.join(" ") || null; } @@ -175,7 +193,7 @@ export function credentialConnectionHint(account?: CredentialAccount): string { if (credentialAuthMethod(account, "claude_code_login")) return "Claude 로그인은 공식 Claude Code로 진행되며 직접 선택해야 시작됩니다. API 키 연결은 별도입니다."; if (credentialAuthMethod(account, "claude_pkce")) return "Claude Code 안내와 THOTH 전용 Claude OAuth는 별도입니다. OAuth 로그인은 직접 선택해야 시작됩니다."; if (xaiDeviceLoginSupported(account) && account?.oauth) return account.available_model_providers?.includes("xai-oauth") - ? "xAI 로그인이 확인됐습니다. 모델 목록에서 xai-oauth 경로를 직접 선택할 수 있습니다. 실제 실행 성공은 별도 확인이 필요합니다." + ? "xAI 로그인이 확인됐습니다. 모델 목록에서 xai-oauth 경로를 직접 선택할 수 있습니다." : "xAI 로그인은 확인됐지만 사용할 모델 경로가 아직 없습니다. 재로그인보다 모델 목록을 확인하세요."; if (xaiDeviceLoginSupported(account)) return "THOTH 작업 공간의 xAI 기기 코드 로그인을 시작하거나 API 키를 별도로 등록할 수 있습니다."; if (account?.oauth && loginSupported(account)) return "Codex 로그인은 확인됐습니다. 모델 목록이 없으면 재로그인 대신 모델 경로를 확인하세요."; diff --git a/apps/web/src/components/modelCredentialStatusNote.test.ts b/apps/web/src/components/modelCredentialStatusNote.test.ts new file mode 100644 index 0000000..eb2e35a --- /dev/null +++ b/apps/web/src/components/modelCredentialStatusNote.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { credentialAvailabilityNote, credentialConnectionHint, type CredentialAccount } from "./modelCredentialPresentation"; + +// A sentence that says "the real model run is not confirmed yet", in any of the wordings the screen has used. +const UNCONFIRMED = /실행 성공은 별도|별도 확인이 필요|첫 연구에서 확인|(확인|검증)되지 않았|미검증/; +const sentences = (text: string | null) => (text ?? "").split(/(?<=[.])\s+/).filter(Boolean); +const unconfirmed = (text: string | null) => sentences(text).filter(sentence => UNCONFIRMED.test(sentence)); + +const chatgpt: CredentialAccount = { + provider: "openai", label: "ChatGPT", connected: true, has_key: false, oauth: true, login_supported: true, + login_kind: "codex_isolated_browser", connection_state: "EXECUTION_UNVERIFIED", profile_mode: "THOTH_ISOLATED", + execution_eligible: true, execution_verified: false, remote_auth_verified: null, available_model_providers: ["codex-oauth"], + guidance: "The Codex route is eligible; live execution has not been verified", +}; +const claude: CredentialAccount = { + provider: "anthropic", label: "Claude", connected: true, has_key: false, oauth: true, login_supported: true, + connection_state: "EXECUTION_UNVERIFIED", execution_eligible: true, execution_verified: false, remote_auth_verified: null, + available_model_providers: ["claude-code"], guidance: "Sign in with the official Claude Code executable in the THOTH profile", + auth_methods: [{ auth_method: "claude_code_login", route: "claude-code", connected: true, execution_eligible: true, capabilities: { start: true } }], +}; +const xai: CredentialAccount = { + provider: "xai", label: "xAI", connected: true, has_key: false, oauth: true, login_supported: true, login_kind: "xai_device_code", + connection_state: "EXECUTION_UNVERIFIED", profile_mode: "THOTH_XAI_OAUTH", execution_eligible: true, execution_verified: false, + remote_auth_verified: null, available_model_providers: ["xai-oauth"], + guidance: "Use the THOTH workspace xAI device login; remote execution is unverified", +}; + +describe("the account note says once that a real run is not confirmed", () => { + it.each([["ChatGPT", chatgpt], ["Claude", claude], ["xAI", xai]] as const)("%s: one such sentence in the note and the hint together", (_name, account) => { + const note = credentialAvailabilityNote(account); + const everything = [note, credentialConnectionHint(account)].join(" "); + expect(unconfirmed(note), note ?? "").toHaveLength(1); + expect(unconfirmed(everything), everything).toHaveLength(1); + }); + + it("keeps the state line and the reason that are not repeats", () => { + const note = credentialAvailabilityNote(chatgpt) ?? ""; + expect(note).toContain("THOTH 전용 별도 프로필"); + expect(note).toContain("Codex 모델 연결은 실험적입니다"); + expect(note).toContain("Codex 경로는 사용을 시도할 수 있지만 실제 실행은 아직 확인되지 않았습니다"); + }); + + it("without the server's guidance it says it from the connection state", () => { + const note = credentialAvailabilityNote({ ...chatgpt, guidance: null }); + expect(unconfirmed(note)).toEqual(["로컬 실행 시도 조건은 확인됐지만 실제 공급자 성공은 아직 검증되지 않았습니다."]); + const connected = credentialAvailabilityNote({ ...chatgpt, guidance: null, connection_state: undefined, execution_verified: undefined }); + expect(unconfirmed(connected)).toEqual(["실제 공급자 호출은 아직 확인되지 않았습니다."]); + }); + + it("says nothing about a real run for an account that is not signed in", () => { + const note = credentialAvailabilityNote({ ...chatgpt, connected: false, connection_state: "LOGIN_REQUIRED", guidance: null, execution_eligible: null }); + expect(unconfirmed(note)).toHaveLength(0); + expect(note).toContain("로그인을 완료한 뒤 연결 상태를 다시 확인하세요"); + }); +}); diff --git a/apps/web/src/components/traceDemo.fixture.json b/apps/web/src/components/traceDemo.fixture.json index 215c1a2..d0bd9e2 100644 --- a/apps/web/src/components/traceDemo.fixture.json +++ b/apps/web/src/components/traceDemo.fixture.json @@ -1 +1 @@ -{"phase1":{"record_digest":"b211df557abe45c80a13ae04e72ca603254c339d1d54330494fe40a6296a2f71","set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":1},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"history_total":1}],"confirmations":[],"pending_changes":[]},"afterConfirm":{"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"items":[{"fields":{},"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","source_span_refs":[],"title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)"},{"fields":{},"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in dry weather"},{"fields":{},"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in fog"},{"fields":{},"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in rain"},{"fields":{},"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in dry weather"},{"fields":{},"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in fog"},{"fields":{},"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in rain"},{"fields":{},"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","source_span_refs":[],"title":"Dry weather run"},{"fields":{},"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","source_span_refs":[],"title":"Fog run"},{"fields":{},"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","source_span_refs":[],"title":"Rain run"},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","source_span_refs":[],"title":""}],"links":[{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-DRY"},{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-DRY"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ref-SYN-C-DET-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ref-SYN-C-DET-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ref-SYN-C-DET-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ref-SYN-C-FA-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ref-SYN-C-FA-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ref-SYN-C-FA-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ver-SYN-C-DET-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ver-SYN-C-DET-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ver-SYN-C-DET-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ver-SYN-C-FA-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ver-SYN-C-FA-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ver-SYN-C-FA-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"}],"pending_changes":[],"policy":{"name":"LATEST_PER_CONDITION","version":1},"record_digest":"bcfd97b752502ce6a07b5e1e980a98e8c7ec641d88201af7ce0a60b58ede6cc7","results":[{"condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","denominator":20,"numerator":19,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.95"},{"condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","denominator":10,"numerator":3,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.30"}],"rules":[{"comparator":">=","condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=fog","criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":"<=","condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=fog","criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"}],"set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","verdicts":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"}],"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"}],"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"}],"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"}],"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"}],"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"}],"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}],"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}]},"rainCsv":"format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at\r\nthoth-trace-csv/1,ITEM,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,SYN-RES-SYN-C-DET-RAIN,,,,,,k-SYN-RES-SYN-C-DET-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,SYN-RES-SYN-C-FA-RAIN,,,,,,k-SYN-RES-SYN-C-FA-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,L-prod-SYN-RES-SYN-C-DET-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-DET-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,L-prod-SYN-RES-SYN-C-FA-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-FA-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,RESULT,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,,,SYN-RES-SYN-C-DET-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-RAIN,,,,,ratio,weather=rain,,,,1,0.80,,16,20,2026-01-02T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,,,SYN-RES-SYN-C-FA-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-RAIN,,,,,per_min,weather=rain,,,,1,0.70,,7,10,2026-01-02T00:00:00.000Z\r\n","rainPreview":{"preview_id":"481a76cd6a4909f369e26040b670b3659be90cb028e041d09c6b27cb5ed5968c","input_sha256":"edee70faa74048744f8348f93d3744452365d23c08394167d94be7bf563bcc7a","mode":"UPDATE","current_set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","applicable":true,"changes":{"added":[{"kind":"ITEM","id":"SYN-RES-SYN-C-DET-RAIN","row":2,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-FA-RAIN","row":3,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-DET-RAIN","row":4,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-FA-RAIN","row":5,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-DET-RAIN","row":6,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-FA-RAIN","row":7,"fields":[]}],"updated":[],"deleted":[],"unchanged":0},"conflicts":[],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]},"rainApply":{"applied":true,"counts":{"added":6,"deleted":0,"updated":0},"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","trace":{"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"items":[{"fields":{},"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","source_span_refs":[],"title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)"},{"fields":{},"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in dry weather"},{"fields":{},"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in fog"},{"fields":{},"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in rain"},{"fields":{},"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in dry weather"},{"fields":{},"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in fog"},{"fields":{},"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in rain"},{"fields":{},"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","source_span_refs":[],"title":"Dry weather run"},{"fields":{},"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","source_span_refs":[],"title":"Fog run"},{"fields":{},"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","source_span_refs":[],"title":"Rain run"},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","source_span_refs":[],"title":""}],"links":[{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-DRY"},{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-DRY"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ref-SYN-C-DET-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ref-SYN-C-DET-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ref-SYN-C-DET-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ref-SYN-C-FA-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ref-SYN-C-FA-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ref-SYN-C-FA-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ver-SYN-C-DET-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ver-SYN-C-DET-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ver-SYN-C-DET-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ver-SYN-C-FA-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ver-SYN-C-FA-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ver-SYN-C-FA-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-TC-RAIN","link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-RAIN"},{"from_id":"SYN-TC-RAIN","link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-RAIN"}],"pending_changes":[],"policy":{"name":"LATEST_PER_CONDITION","version":1},"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","results":[{"condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","denominator":20,"numerator":19,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.95"},{"condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","denominator":10,"numerator":3,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.30"},{"condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","denominator":20,"numerator":16,"observed_at":"2026-01-02T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1,"source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.80"},{"condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","denominator":10,"numerator":7,"observed_at":"2026-01-02T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.70"}],"rules":[{"comparator":">=","condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=fog","criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":"<=","condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=fog","criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"}],"set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","verdicts":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"}],"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"}],"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"}],"revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"}],"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"}],"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"}],"revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"],"evidence_text_preserved":false,"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]},{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"],"evidence_text_preserved":false,"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]},{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","selection":null,"state":"FAIL_WITH_INCOMPLETE_COVERAGE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}],"revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","selection":null,"state":"FAIL_WITH_INCOMPLETE_COVERAGE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b"}]},"verdict_changes":[{"after":"FAIL_COMPUTED","before":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION"},{"after":"FAIL_COMPUTED","before":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION"},{"after":"FAIL_WITH_INCOMPLETE_COVERAGE","before":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT"}]},"phase2":{"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1,"criterion_id":"SYN-C-DET-RAIN","condition":"weather=rain","value":"0.80","raw_value":null,"unit":"ratio","numerator":16,"denominator":20,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"criterion_id":"SYN-C-FA-RAIN","condition":"weather=rain","value":"0.70","raw_value":null,"unit":"per_min","numerator":7,"denominator":10,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"history_total":2},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71"},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":2},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b","revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b","revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"history_total":2}],"confirmations":[{"verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인"}],"pending_changes":[]},"staleBasePreview":{"preview_id":"f2136b8f9990d5830ba20441a728bd7a9514b235ccd557c65ca530d78c97588e","input_sha256":"7ccce9542473ae78adf427634279bed68906934ba412419c6b55d16b98a5bb4c","mode":"UPDATE","current_set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","applicable":false,"changes":{"added":[],"updated":[],"deleted":[],"unchanged":0},"conflicts":[{"code":"STALE_BASE","row":null,"ref":null,"detail":"the file was made from set c21049d475ad, the trace is now 05d78ab9d6ef"}],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]},"exported":{"format":"thoth-trace-csv/1","filename":"thoth-trace.csv","csv_text":"format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-REQ-RAD-001,,,,,,k-req,REQUIREMENT,\"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)\",,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-DRY,,,,,,k-c0,CRITERION,Detection rate in dry weather,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-FOG,,,,,,k-c4,CRITERION,Detection rate in fog,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-RAIN,,,,,,k-c2,CRITERION,Detection rate in rain,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-DRY,,,,,,k-c1,CRITERION,False tracks per minute in dry weather,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-FOG,,,,,,k-c5,CRITERION,False tracks per minute in fog,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-RAIN,,,,,,k-c3,CRITERION,False tracks per minute in rain,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-DRY,,,,,,k-t0,TEST_CASE,Dry weather run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-FOG,,,,,,k-t2,TEST_CASE,Fog run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-RAIN,,,,,,k-t1,TEST_CASE,Rain run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-DET-DRY,,,,,,k-SYN-RES-SYN-C-DET-DRY,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-DET-RAIN,,,,,,k-SYN-RES-SYN-C-DET-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-FA-DRY,,,,,,k-SYN-RES-SYN-C-FA-DRY,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-FA-RAIN,,,,,,k-SYN-RES-SYN-C-FA-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-DET-DRY,,,,,,,,,,SYN-TC-DRY,SYN-RES-SYN-C-DET-DRY,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-DET-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-DET-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-FA-DRY,,,,,,,,,,SYN-TC-DRY,SYN-RES-SYN-C-FA-DRY,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-FA-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-FA-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-DRY,,,,,,,,,,SYN-C-DET-DRY,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-FOG,,,,,,,,,,SYN-C-DET-FOG,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-RAIN,,,,,,,,,,SYN-C-DET-RAIN,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-DRY,,,,,,,,,,SYN-C-FA-DRY,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-FOG,,,,,,,,,,SYN-C-FA-FOG,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-RAIN,,,,,,,,,,SYN-C-FA-RAIN,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-DRY,,,,,,,,,,SYN-C-DET-DRY,SYN-TC-DRY,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-FOG,,,,,,,,,,SYN-C-DET-FOG,SYN-TC-FOG,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-RAIN,,,,,,,,,,SYN-C-DET-RAIN,SYN-TC-RAIN,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-DRY,,,,,,,,,,SYN-C-FA-DRY,SYN-TC-DRY,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-FOG,,,,,,,,,,SYN-C-FA-FOG,SYN-TC-FOG,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-RAIN,,,,,,,,,,SYN-C-FA-RAIN,SYN-TC-RAIN,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-DRY,,,,,,,,,,,,SYN-C-DET-DRY,1,detection_rate,>=,0.90,ratio,weather=dry,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-FOG,,,,,,,,,,,,SYN-C-DET-FOG,1,detection_rate,>=,0.90,ratio,weather=fog,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-RAIN,,,,,,,,,,,,SYN-C-DET-RAIN,1,detection_rate,>=,0.90,ratio,weather=rain,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-DRY,,,,,,,,,,,,SYN-C-FA-DRY,1,false_track_rate,<=,0.50,per_min,weather=dry,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-FOG,,,,,,,,,,,,SYN-C-FA-FOG,1,false_track_rate,<=,0.50,per_min,weather=fog,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-RAIN,,,,,,,,,,,,SYN-C-FA-RAIN,1,false_track_rate,<=,0.50,per_min,weather=rain,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-DET-DRY,,,,,,,\"[\"\"30_RESULT_DRY_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-DRY,,,,,ratio,weather=dry,,,,1,0.95,,19,20,2026-01-01T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-DET-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-RAIN,,,,,ratio,weather=rain,,,,1,0.80,,16,20,2026-01-02T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-FA-DRY,,,,,,,\"[\"\"30_RESULT_DRY_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-DRY,,,,,per_min,weather=dry,,,,1,0.30,,3,10,2026-01-01T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-FA-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-RAIN,,,,,per_min,weather=rain,,,,1,0.70,,7,10,2026-01-02T00:00:00.000Z\r\n","sha256":"9a1455acd35b2cab1644c04eda69ea01902bb3a3ccf4be207b6dbf6f181842ae","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","loss_manifest":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"]},"many":{"record_digest":"a1d5d714866ec602a91e51ae16ca363379c7938aa5e731221cfc6f51545dc184","set_digest":"c005b0743c780608bada70898aa5a3f9549b35f8593fb39d3e2331d3800ab18f","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5,"criterion_id":"SYN-C-DET-RAIN","condition":"weather=rain","value":"0.73","raw_value":null,"unit":"ratio","numerator":16,"denominator":20,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"criterion_id":"SYN-C-FA-RAIN","condition":"weather=rain","value":"0.70","raw_value":null,"unit":"per_min","numerator":7,"denominator":10,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"a27270f0eedce47ba0170b4f0d3965e42912c1b84a149c36af6da210933de8e5","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2","verdict_digest":"2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","revision_digest":"8cddb51c2c669746d8902a7494400a9173f2b634492922cad95141fa03a9955e","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"a27270f0eedce47ba0170b4f0d3965e42912c1b84a149c36af6da210933de8e5","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2","verdict_digest":"2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","revision_digest":"8cddb51c2c669746d8902a7494400a9173f2b634492922cad95141fa03a9955e"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"3","after":"4","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.71","after":"0.72","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"72da4288279788f6013fa28e90bcc31d25dea3bce0a6384f3ee13cb56f4bffc7","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":4}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":4}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.992000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"eb71cc0500d2815af9a73ccd1cfb4bb86966de608c0e453aec14e8d8e0f63c10","verdict_digest":"314fda9f8924c05350f628174cbc3ebb447fed03d6bd84adf601ce8a1ea14751","revision_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"2","after":"3","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.70","after":"0.71","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"38c678e678d9a85033bd45a3c8738732828e75f8494462e314f2ca16a5d8f0dd","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":3}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":3}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.868000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"2c320f9d6cc4c1667c075d6c9f4570d6b13cc0391f0be8dc8e2c5d439cfae0ea","verdict_digest":"d820faf9dd0b4dba29a528980bdc682356c9c5139374c93717f78fbc54137e4b","revision_digest":"eb71cc0500d2815af9a73ccd1cfb4bb86966de608c0e453aec14e8d8e0f63c10"}],"history_total":6},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71"},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":2},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"4950dc93efd45400c2254f22021e9fd49030237ec96719673f8c8ebc03b12397","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e","verdict_digest":"6eec4d8d1d72d9019c75432e76c0ead69b3e162584c41888cf6fe660f97b2125","revision_digest":"ac1444fefde19353116ad0da8e121d9da35ec9844d38369a77fa53725c01a2e0","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"4950dc93efd45400c2254f22021e9fd49030237ec96719673f8c8ebc03b12397","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e","verdict_digest":"6eec4d8d1d72d9019c75432e76c0ead69b3e162584c41888cf6fe660f97b2125","revision_digest":"ac1444fefde19353116ad0da8e121d9da35ec9844d38369a77fa53725c01a2e0"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"3","after":"4","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.71","after":"0.72","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"e9e57239b9fffb2547cf726c08d4e05c7277f6748c7ddc0c149105b20acf1a38","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","314fda9f8924c05350f628174cbc3ebb447fed03d6bd84adf601ce8a1ea14751","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.992000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"a2089935665220df2fc32ab1464bd205f972eba3b5599fd94c39efe29aabf9e3","verdict_digest":"eeb3e9c47740e6b900a43a90b4c8ca93cec09f6240410b7969fe086e7cd2db60","revision_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"2","after":"3","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.70","after":"0.71","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"81617c390af53efe7fd546c67557bf5fdd5134dae683a472a1eedfc5c984c287","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","d820faf9dd0b4dba29a528980bdc682356c9c5139374c93717f78fbc54137e4b","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.868000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"044b1047512b9226b187650ee6cb24198db18a17ede0cc2451a422d87c1d24da","verdict_digest":"28db0d9093e9540f803e420153d1c4444f1f76dcf77543b28c68df3f2c282126","revision_digest":"a2089935665220df2fc32ab1464bd205f972eba3b5599fd94c39efe29aabf9e3"}],"history_total":6}],"confirmations":[{"verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인"}],"pending_changes":[]},"manyOlder":{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","history_total":6,"revisions":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"1","after":"2","before_revision":1,"after_revision":2,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.80","after":"0.70","before_revision":1,"after_revision":2,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"af67b2bd1b27f3a75190e34b065ef9f7a633f35194219beb5ed3e076f416431b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":2}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":2}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.746000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","verdict_digest":"0ac17bb5810ee4e1f05018ef25661cd6659725a899b7ff00480ac7cc2769b5a1","revision_digest":"2c320f9d6cc4c1667c075d6c9f4570d6b13cc0391f0be8dc8e2c5d439cfae0ea"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"next_before_digest":null},"empty":{"record_digest":null,"set_digest":"642f3b54cb097968bf34372fb706b8258bfaa069fa3d9b857de5971276653cd5","items":[],"links":[],"rules":[],"results":[],"policy":null,"verdicts":[],"confirmations":[],"pending_changes":[]},"createPreview":{"preview_id":"78d35dd67638954f40502c6392cd66aff83b57f2ceaa407f3a9ed0138e6a83ad","input_sha256":"7ccce9542473ae78adf427634279bed68906934ba412419c6b55d16b98a5bb4c","mode":"CREATE","current_set_digest":null,"applicable":true,"changes":{"added":[{"kind":"ITEM","id":"SYN-REQ-RAD-001","row":2,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-DRY","row":3,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-FOG","row":4,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-RAIN","row":5,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-DRY","row":6,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-FOG","row":7,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-RAIN","row":8,"fields":[]},{"kind":"ITEM","id":"SYN-TC-DRY","row":9,"fields":[]},{"kind":"ITEM","id":"SYN-TC-FOG","row":10,"fields":[]},{"kind":"ITEM","id":"SYN-TC-RAIN","row":11,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-DET-DRY","row":12,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-FA-DRY","row":13,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-DET-DRY","row":14,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-FA-DRY","row":15,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-DRY","row":16,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-FOG","row":17,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-RAIN","row":18,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-DRY","row":19,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-FOG","row":20,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-RAIN","row":21,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-DRY","row":22,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-FOG","row":23,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-RAIN","row":24,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-DRY","row":25,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-FOG","row":26,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-RAIN","row":27,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-DRY","row":28,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-FOG","row":29,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-RAIN","row":30,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-DRY","row":31,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-FOG","row":32,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-RAIN","row":33,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-DET-DRY","row":34,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-FA-DRY","row":35,"fields":[]}],"updated":[],"deleted":[],"unchanged":0},"conflicts":[],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]}} +{"phase1":{"record_digest":"b211df557abe45c80a13ae04e72ca603254c339d1d54330494fe40a6296a2f71","set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":1},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"history_total":1}],"confirmations":[],"pending_changes":[]},"afterConfirm":{"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"items":[{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","source_span_refs":[],"title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in dry weather"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in fog"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in rain"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in dry weather"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in fog"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in rain"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","source_span_refs":[],"title":"Dry weather run"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","source_span_refs":[],"title":"Fog run"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","source_span_refs":[],"title":"Rain run"},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","source_span_refs":[],"title":""}],"links":[{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-DRY"},{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-DRY"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ref-SYN-C-DET-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ref-SYN-C-DET-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ref-SYN-C-DET-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ref-SYN-C-FA-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ref-SYN-C-FA-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ref-SYN-C-FA-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ver-SYN-C-DET-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ver-SYN-C-DET-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ver-SYN-C-DET-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ver-SYN-C-FA-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ver-SYN-C-FA-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ver-SYN-C-FA-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"}],"pending_changes":[],"policy":{"name":"LATEST_PER_CONDITION","version":1},"record_digest":"bcfd97b752502ce6a07b5e1e980a98e8c7ec641d88201af7ce0a60b58ede6cc7","results":[{"condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","denominator":20,"numerator":19,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.95"},{"condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","denominator":10,"numerator":3,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.30"}],"rules":[{"comparator":">=","condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=fog","criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":"<=","condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=fog","criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"}],"set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","verdicts":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"}],"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"}],"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"}],"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"}],"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"}],"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"}],"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}],"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}]},"rainCsv":"format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at\r\nthoth-trace-csv/1,ITEM,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,SYN-RES-SYN-C-DET-RAIN,,,,,,k-SYN-RES-SYN-C-DET-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,SYN-RES-SYN-C-FA-RAIN,,,,,,k-SYN-RES-SYN-C-FA-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,L-prod-SYN-RES-SYN-C-DET-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-DET-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,L-prod-SYN-RES-SYN-C-FA-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-FA-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,RESULT,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,,,SYN-RES-SYN-C-DET-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-RAIN,,,,,ratio,weather=rain,,,,1,0.80,,16,20,2026-01-02T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,,,SYN-RES-SYN-C-FA-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-RAIN,,,,,per_min,weather=rain,,,,1,0.70,,7,10,2026-01-02T00:00:00.000Z\r\n","rainPreview":{"preview_id":"481a76cd6a4909f369e26040b670b3659be90cb028e041d09c6b27cb5ed5968c","input_sha256":"edee70faa74048744f8348f93d3744452365d23c08394167d94be7bf563bcc7a","mode":"UPDATE","current_set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","applicable":true,"changes":{"added":[{"kind":"ITEM","id":"SYN-RES-SYN-C-DET-RAIN","row":2,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-FA-RAIN","row":3,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-DET-RAIN","row":4,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-FA-RAIN","row":5,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-DET-RAIN","row":6,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-FA-RAIN","row":7,"fields":[]}],"updated":[],"deleted":[],"unchanged":0},"conflicts":[],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]},"rainApply":{"applied":true,"counts":{"added":6,"deleted":0,"updated":0},"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","trace":{"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"items":[{"fields":{},"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","source_span_refs":[],"title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)"},{"fields":{},"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in dry weather"},{"fields":{},"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in fog"},{"fields":{},"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in rain"},{"fields":{},"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in dry weather"},{"fields":{},"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in fog"},{"fields":{},"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in rain"},{"fields":{},"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","source_span_refs":[],"title":"Dry weather run"},{"fields":{},"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","source_span_refs":[],"title":"Fog run"},{"fields":{},"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","source_span_refs":[],"title":"Rain run"},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","source_span_refs":[],"title":""}],"links":[{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-DRY"},{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-DRY"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ref-SYN-C-DET-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ref-SYN-C-DET-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ref-SYN-C-DET-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ref-SYN-C-FA-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ref-SYN-C-FA-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ref-SYN-C-FA-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ver-SYN-C-DET-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ver-SYN-C-DET-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ver-SYN-C-DET-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ver-SYN-C-FA-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ver-SYN-C-FA-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ver-SYN-C-FA-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-TC-RAIN","link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-RAIN"},{"from_id":"SYN-TC-RAIN","link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-RAIN"}],"pending_changes":[],"policy":{"name":"LATEST_PER_CONDITION","version":1},"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","results":[{"condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","denominator":20,"numerator":19,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.95"},{"condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","denominator":10,"numerator":3,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.30"},{"condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","denominator":20,"numerator":16,"observed_at":"2026-01-02T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1,"source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.80"},{"condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","denominator":10,"numerator":7,"observed_at":"2026-01-02T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.70"}],"rules":[{"comparator":">=","condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=fog","criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":"<=","condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=fog","criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"}],"set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","verdicts":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"}],"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"}],"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"}],"revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"}],"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"}],"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"}],"revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"],"evidence_text_preserved":false,"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]},{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"],"evidence_text_preserved":false,"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]},{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","selection":null,"state":"FAIL_WITH_INCOMPLETE_COVERAGE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}],"revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","selection":null,"state":"FAIL_WITH_INCOMPLETE_COVERAGE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b"}]},"verdict_changes":[{"after":"FAIL_COMPUTED","before":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION"},{"after":"FAIL_COMPUTED","before":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION"},{"after":"FAIL_WITH_INCOMPLETE_COVERAGE","before":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT"}]},"phase2":{"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1,"criterion_id":"SYN-C-DET-RAIN","condition":"weather=rain","value":"0.80","raw_value":null,"unit":"ratio","numerator":16,"denominator":20,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"criterion_id":"SYN-C-FA-RAIN","condition":"weather=rain","value":"0.70","raw_value":null,"unit":"per_min","numerator":7,"denominator":10,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"history_total":2},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71"},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":2},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b","revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b","revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"history_total":2}],"confirmations":[{"verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인"}],"pending_changes":[]},"staleBasePreview":{"preview_id":"f2136b8f9990d5830ba20441a728bd7a9514b235ccd557c65ca530d78c97588e","input_sha256":"7ccce9542473ae78adf427634279bed68906934ba412419c6b55d16b98a5bb4c","mode":"UPDATE","current_set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","applicable":false,"changes":{"added":[],"updated":[],"deleted":[],"unchanged":0},"conflicts":[{"code":"STALE_BASE","row":null,"ref":null,"detail":"the file was made from set c21049d475ad, the trace is now 05d78ab9d6ef"}],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]},"exported":{"format":"thoth-trace-csv/1","filename":"thoth-trace.csv","csv_text":"format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-REQ-RAD-001,,,,,,k-req,REQUIREMENT,\"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)\",,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-DRY,,,,,,k-c0,CRITERION,Detection rate in dry weather,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-FOG,,,,,,k-c4,CRITERION,Detection rate in fog,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-RAIN,,,,,,k-c2,CRITERION,Detection rate in rain,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-DRY,,,,,,k-c1,CRITERION,False tracks per minute in dry weather,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-FOG,,,,,,k-c5,CRITERION,False tracks per minute in fog,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-RAIN,,,,,,k-c3,CRITERION,False tracks per minute in rain,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-DRY,,,,,,k-t0,TEST_CASE,Dry weather run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-FOG,,,,,,k-t2,TEST_CASE,Fog run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-RAIN,,,,,,k-t1,TEST_CASE,Rain run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-DET-DRY,,,,,,k-SYN-RES-SYN-C-DET-DRY,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-DET-RAIN,,,,,,k-SYN-RES-SYN-C-DET-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-FA-DRY,,,,,,k-SYN-RES-SYN-C-FA-DRY,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-FA-RAIN,,,,,,k-SYN-RES-SYN-C-FA-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-DET-DRY,,,,,,,,,,SYN-TC-DRY,SYN-RES-SYN-C-DET-DRY,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-DET-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-DET-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-FA-DRY,,,,,,,,,,SYN-TC-DRY,SYN-RES-SYN-C-FA-DRY,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-FA-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-FA-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-DRY,,,,,,,,,,SYN-C-DET-DRY,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-FOG,,,,,,,,,,SYN-C-DET-FOG,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-RAIN,,,,,,,,,,SYN-C-DET-RAIN,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-DRY,,,,,,,,,,SYN-C-FA-DRY,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-FOG,,,,,,,,,,SYN-C-FA-FOG,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-RAIN,,,,,,,,,,SYN-C-FA-RAIN,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-DRY,,,,,,,,,,SYN-C-DET-DRY,SYN-TC-DRY,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-FOG,,,,,,,,,,SYN-C-DET-FOG,SYN-TC-FOG,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-RAIN,,,,,,,,,,SYN-C-DET-RAIN,SYN-TC-RAIN,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-DRY,,,,,,,,,,SYN-C-FA-DRY,SYN-TC-DRY,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-FOG,,,,,,,,,,SYN-C-FA-FOG,SYN-TC-FOG,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-RAIN,,,,,,,,,,SYN-C-FA-RAIN,SYN-TC-RAIN,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-DRY,,,,,,,,,,,,SYN-C-DET-DRY,1,detection_rate,>=,0.90,ratio,weather=dry,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-FOG,,,,,,,,,,,,SYN-C-DET-FOG,1,detection_rate,>=,0.90,ratio,weather=fog,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-RAIN,,,,,,,,,,,,SYN-C-DET-RAIN,1,detection_rate,>=,0.90,ratio,weather=rain,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-DRY,,,,,,,,,,,,SYN-C-FA-DRY,1,false_track_rate,<=,0.50,per_min,weather=dry,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-FOG,,,,,,,,,,,,SYN-C-FA-FOG,1,false_track_rate,<=,0.50,per_min,weather=fog,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-RAIN,,,,,,,,,,,,SYN-C-FA-RAIN,1,false_track_rate,<=,0.50,per_min,weather=rain,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-DET-DRY,,,,,,,\"[\"\"30_RESULT_DRY_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-DRY,,,,,ratio,weather=dry,,,,1,0.95,,19,20,2026-01-01T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-DET-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-RAIN,,,,,ratio,weather=rain,,,,1,0.80,,16,20,2026-01-02T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-FA-DRY,,,,,,,\"[\"\"30_RESULT_DRY_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-DRY,,,,,per_min,weather=dry,,,,1,0.30,,3,10,2026-01-01T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-FA-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-RAIN,,,,,per_min,weather=rain,,,,1,0.70,,7,10,2026-01-02T00:00:00.000Z\r\n","sha256":"9a1455acd35b2cab1644c04eda69ea01902bb3a3ccf4be207b6dbf6f181842ae","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","loss_manifest":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"]},"many":{"record_digest":"a1d5d714866ec602a91e51ae16ca363379c7938aa5e731221cfc6f51545dc184","set_digest":"c005b0743c780608bada70898aa5a3f9549b35f8593fb39d3e2331d3800ab18f","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","title":"","fields":{"synthetic_notice":"SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5,"criterion_id":"SYN-C-DET-RAIN","condition":"weather=rain","value":"0.73","raw_value":null,"unit":"ratio","numerator":16,"denominator":20,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"criterion_id":"SYN-C-FA-RAIN","condition":"weather=rain","value":"0.70","raw_value":null,"unit":"per_min","numerator":7,"denominator":10,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"a27270f0eedce47ba0170b4f0d3965e42912c1b84a149c36af6da210933de8e5","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2","verdict_digest":"2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","revision_digest":"8cddb51c2c669746d8902a7494400a9173f2b634492922cad95141fa03a9955e","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"a27270f0eedce47ba0170b4f0d3965e42912c1b84a149c36af6da210933de8e5","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2","verdict_digest":"2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","revision_digest":"8cddb51c2c669746d8902a7494400a9173f2b634492922cad95141fa03a9955e"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"3","after":"4","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.71","after":"0.72","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"72da4288279788f6013fa28e90bcc31d25dea3bce0a6384f3ee13cb56f4bffc7","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":4}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":4}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.992000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"eb71cc0500d2815af9a73ccd1cfb4bb86966de608c0e453aec14e8d8e0f63c10","verdict_digest":"314fda9f8924c05350f628174cbc3ebb447fed03d6bd84adf601ce8a1ea14751","revision_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"2","after":"3","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.70","after":"0.71","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"38c678e678d9a85033bd45a3c8738732828e75f8494462e314f2ca16a5d8f0dd","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":3}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":3}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.868000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"2c320f9d6cc4c1667c075d6c9f4570d6b13cc0391f0be8dc8e2c5d439cfae0ea","verdict_digest":"d820faf9dd0b4dba29a528980bdc682356c9c5139374c93717f78fbc54137e4b","revision_digest":"eb71cc0500d2815af9a73ccd1cfb4bb86966de608c0e453aec14e8d8e0f63c10"}],"history_total":6},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71"},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":2},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"4950dc93efd45400c2254f22021e9fd49030237ec96719673f8c8ebc03b12397","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e","verdict_digest":"6eec4d8d1d72d9019c75432e76c0ead69b3e162584c41888cf6fe660f97b2125","revision_digest":"ac1444fefde19353116ad0da8e121d9da35ec9844d38369a77fa53725c01a2e0","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"4950dc93efd45400c2254f22021e9fd49030237ec96719673f8c8ebc03b12397","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e","verdict_digest":"6eec4d8d1d72d9019c75432e76c0ead69b3e162584c41888cf6fe660f97b2125","revision_digest":"ac1444fefde19353116ad0da8e121d9da35ec9844d38369a77fa53725c01a2e0"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"3","after":"4","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.71","after":"0.72","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"e9e57239b9fffb2547cf726c08d4e05c7277f6748c7ddc0c149105b20acf1a38","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","314fda9f8924c05350f628174cbc3ebb447fed03d6bd84adf601ce8a1ea14751","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.992000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"a2089935665220df2fc32ab1464bd205f972eba3b5599fd94c39efe29aabf9e3","verdict_digest":"eeb3e9c47740e6b900a43a90b4c8ca93cec09f6240410b7969fe086e7cd2db60","revision_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"2","after":"3","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.70","after":"0.71","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"81617c390af53efe7fd546c67557bf5fdd5134dae683a472a1eedfc5c984c287","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","d820faf9dd0b4dba29a528980bdc682356c9c5139374c93717f78fbc54137e4b","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.868000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"044b1047512b9226b187650ee6cb24198db18a17ede0cc2451a422d87c1d24da","verdict_digest":"28db0d9093e9540f803e420153d1c4444f1f76dcf77543b28c68df3f2c282126","revision_digest":"a2089935665220df2fc32ab1464bd205f972eba3b5599fd94c39efe29aabf9e3"}],"history_total":6}],"confirmations":[{"verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인"}],"pending_changes":[]},"manyOlder":{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","history_total":6,"revisions":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"1","after":"2","before_revision":1,"after_revision":2,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.80","after":"0.70","before_revision":1,"after_revision":2,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"af67b2bd1b27f3a75190e34b065ef9f7a633f35194219beb5ed3e076f416431b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":2}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":2}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.746000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","verdict_digest":"0ac17bb5810ee4e1f05018ef25661cd6659725a899b7ff00480ac7cc2769b5a1","revision_digest":"2c320f9d6cc4c1667c075d6c9f4570d6b13cc0391f0be8dc8e2c5d439cfae0ea"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"next_before_digest":null},"empty":{"record_digest":null,"set_digest":"642f3b54cb097968bf34372fb706b8258bfaa069fa3d9b857de5971276653cd5","items":[],"links":[],"rules":[],"results":[],"policy":null,"verdicts":[],"confirmations":[],"pending_changes":[]},"createPreview":{"preview_id":"78d35dd67638954f40502c6392cd66aff83b57f2ceaa407f3a9ed0138e6a83ad","input_sha256":"7ccce9542473ae78adf427634279bed68906934ba412419c6b55d16b98a5bb4c","mode":"CREATE","current_set_digest":null,"applicable":true,"changes":{"added":[{"kind":"ITEM","id":"SYN-REQ-RAD-001","row":2,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-DRY","row":3,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-FOG","row":4,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-RAIN","row":5,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-DRY","row":6,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-FOG","row":7,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-RAIN","row":8,"fields":[]},{"kind":"ITEM","id":"SYN-TC-DRY","row":9,"fields":[]},{"kind":"ITEM","id":"SYN-TC-FOG","row":10,"fields":[]},{"kind":"ITEM","id":"SYN-TC-RAIN","row":11,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-DET-DRY","row":12,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-FA-DRY","row":13,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-DET-DRY","row":14,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-FA-DRY","row":15,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-DRY","row":16,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-FOG","row":17,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-RAIN","row":18,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-DRY","row":19,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-FOG","row":20,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-RAIN","row":21,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-DRY","row":22,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-FOG","row":23,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-RAIN","row":24,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-DRY","row":25,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-FOG","row":26,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-RAIN","row":27,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-DRY","row":28,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-FOG","row":29,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-RAIN","row":30,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-DRY","row":31,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-FOG","row":32,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-RAIN","row":33,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-DET-DRY","row":34,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-FA-DRY","row":35,"fields":[]}],"updated":[],"deleted":[],"unchanged":0},"conflicts":[],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]}} diff --git a/apps/web/src/components/traceInvestigation.test.ts b/apps/web/src/components/traceInvestigation.test.ts index 167f4a9..15a192a 100644 --- a/apps/web/src/components/traceInvestigation.test.ts +++ b/apps/web/src/components/traceInvestigation.test.ts @@ -36,7 +36,7 @@ describe("the origin and the question a row gives", () => { const found = investigationFor("project:radar", "CRITERION", verdict(phase2, "CRITERION", "Detection rate in rain"), phase2)!; expect(found.question).toContain("Detection rate in rain"); expect(found.question).toMatch(/기준 미달/); - expect(found.question).toMatch(/측정값 [0-9.]+이\(가\) 기준 [0-9.]+ 이상/); + expect(found.question).toMatch(/측정값 [0-9.]+[이가] 기준 [0-9.]+ 이상/); expect(found.question).toContain("원인 후보와 그것을 가를 시험을 찾아 주세요."); }); diff --git a/apps/web/src/components/traceRows.ts b/apps/web/src/components/traceRows.ts index 135f036..99a6241 100644 --- a/apps/web/src/components/traceRows.ts +++ b/apps/web/src/components/traceRows.ts @@ -42,5 +42,5 @@ export function resultText(result: TraceResult) { const MARK = "SYNTHETIC DEMO DATA"; /** Data written for a demo says so on the screen; the table itself cannot tell a demo from real values. */ export const isSyntheticTrace = (view: TraceView) => - view.items.some(item => item.item_id.startsWith("SYN-") || item.title.includes(MARK) + view.items.some(item => item.title.includes(MARK) || Object.entries(item.fields).some(([key, value]) => key === "synthetic_notice" || value.includes(MARK))); diff --git a/apps/web/src/components/traceText.test.ts b/apps/web/src/components/traceText.test.ts index 27504b7..8d68fed 100644 --- a/apps/web/src/components/traceText.test.ts +++ b/apps/web/src/components/traceText.test.ts @@ -47,8 +47,8 @@ describe("trace wording stays in step with the backend", () => { expect(sentence, code).not.toContain("이유를 문장으로 바꾸지 못했습니다"); expect(sentence, code).not.toMatch(RAW_CODE); } - expect(reasonSentence("THRESHOLD_NOT_MET:R-1:value=0.80 need >= 0.90 ratio", names)).toBe("측정값 0.80이(가) 기준 0.90 이상(ratio)에 못 미칩니다."); - expect(reasonSentence("THRESHOLD_NOT_MET:R-2:value=0.70 need <= 0.50 per_min", names)).toBe("측정값 0.70이(가) 기준 0.50 이하(per_min)를 넘습니다."); + expect(reasonSentence("THRESHOLD_NOT_MET:R-1:value=0.80 need >= 0.90 ratio", names)).toBe("측정값 0.80이 기준 0.90 이상(ratio)에 못 미칩니다."); + expect(reasonSentence("THRESHOLD_NOT_MET:R-2:value=0.70 need <= 0.50 per_min", names)).toBe("측정값 0.70이 기준 0.50 이하(per_min)를 넘습니다."); expect(reasonSentence("C-1:HOLD_NO_RESULT", names)).toBe("탐지율(맑음): 보류 · 결과 없음"); }); @@ -192,3 +192,31 @@ describe("the next check for a held or failed line", () => { } }); }); + +describe("particles after a value or an id follow how it is spoken", () => { + const names = (id: string) => id; + const sentence = (value: string) => reasonSentence(`THRESHOLD_NOT_MET:R-1:value=${value} need >= 0.90 ratio`, names); + + it("picks 이 or 가 after the measured value", () => { + expect(sentence("0.80")).toBe("측정값 0.80이 기준 0.90 이상(ratio)에 못 미칩니다."); + expect(sentence("0.95")).toBe("측정값 0.95가 기준 0.90 이상(ratio)에 못 미칩니다."); + expect(sentence("16/20")).toBe("측정값 16/20이 기준 0.90 이상(ratio)에 못 미칩니다."); + expect(reasonSentence("THRESHOLD_NOT_MET:R-2:value=0.70 need <= 0.50 per_min", names)).toBe("측정값 0.70이 기준 0.50 이하(per_min)를 넘습니다."); + expect(sentence("0.72")).toContain("0.72가 기준"); + expect(sentence("0.45")).toContain("0.45가 기준"); + // never both forms together for a number + for (const value of ["0.80", "0.95", "16/20", "0.70", "0.9", "1"]) expect(sentence(value)).not.toContain("이(가)"); + }); + + it("picks the particle after the id in an import problem", () => { + const said = (code: string, ref: string | null) => issueText({ code, row: 4, ref, detail: "" }).message; + expect(said("DELETE_TARGET_NOT_FOUND", "SYN-C-DET-DRY")).toContain("지우려는 것 SYN-C-DET-DRY가 추적표에 없습니다"); + expect(said("DELETE_TARGET_NOT_FOUND", "SYN-C-DET-RAIN")).toContain("지우려는 것 SYN-C-DET-RAIN이 추적표에 없습니다"); + expect(said("DELETE_AND_UPSERT_SAME_TARGET", "SYN-C-DET-DRY")).toContain("SYN-C-DET-DRY를 같은 파일에서 고치면서"); + expect(said("DELETE_AND_UPSERT_SAME_TARGET", "SYN-C-FA-RAIN")).toContain("SYN-C-FA-RAIN을 같은 파일에서 고치면서"); + expect(said("DUPLICATE_ID_IN_FILE", "SYN-C-DET-DRY")).toContain("같은 ID SYN-C-DET-DRY가 파일에 두 번"); + expect(said("DUPLICATE_ID_IN_FILE", "SYN-C-DET-RAIN")).toContain("같은 ID SYN-C-DET-RAIN이 파일에 두 번"); + expect(said("EXCEL_ID_SUSPECTED", "SYN-C-DET-DRY")).toContain("ID SYN-C-DET-DRY는 스프레드시트가"); + expect(said("EXCEL_ID_SUSPECTED", "SYN-C-DET-RAIN")).toContain("ID SYN-C-DET-RAIN은 스프레드시트가"); + }); +}); diff --git a/apps/web/src/components/traceText.ts b/apps/web/src/components/traceText.ts index dd8d529..4f109c5 100644 --- a/apps/web/src/components/traceText.ts +++ b/apps/web/src/components/traceText.ts @@ -1,3 +1,4 @@ +import { eulreul, eunneun, iga } from "../api/koreanParticles"; import type { ChangedDependency, ImportIssue, TraceResult, TraceRule, VerdictChange } from "../api/trace"; import { resultText, ruleText } from "./traceRows"; import type { ReasonTagId } from "./traceReasonTags"; @@ -109,8 +110,8 @@ export function reasonSentence(code: string, titleOf: (id: string) => string, co if (!match) return "측정값이 기준을 만족하지 못합니다."; const unit = match[4] ? "(" + match[4] + ")" : ""; return match[2] === ">=" - ? "측정값 " + match[1] + "이(가) 기준 " + match[3] + " 이상" + unit + "에 못 미칩니다." - : "측정값 " + match[1] + "이(가) 기준 " + match[3] + " 이하" + unit + "를 넘습니다."; + ? "측정값 " + iga(match[1]) + " 기준 " + match[3] + " 이상" + unit + "에 못 미칩니다." + : "측정값 " + iga(match[1]) + " 기준 " + match[3] + " 이하" + unit + "를 넘습니다."; } if (head === "UNIT_MISMATCH") { const match = /:([^!]*)!=(.*)$/.exec(tail); @@ -210,17 +211,17 @@ export function issueText(issue: ImportIssue): IssueText { BASE_DIGEST_INCONSISTENT: () => ({ message: "한 파일 안에 서로 다른 상태에서 내보낸 줄이 섞여 있습니다.", next: "한 번에 내보낸 파일 하나만 고치세요." }), MODE_CREATE_NEEDS_EMPTY_PROJECT: () => ({ message: "이 프로젝트에는 이미 추적표가 있어 '새로 만들기'를 할 수 없습니다.", next: "'기존 표 고치기'를 선택하세요." }), UPDATE_NEEDS_EXISTING_TRACE: () => ({ message: "고칠 추적표가 아직 없습니다.", next: "'새로 만들기'를 선택하세요." }), - DUPLICATE_ID_IN_FILE: () => ({ message: `같은 ID${ref(issue)}가 파일에 두 번 이상 있습니다${where(issue)}.`, next: "중복된 줄을 하나로 정리하세요." }), + DUPLICATE_ID_IN_FILE: () => ({ message: `${iga("같은 ID" + ref(issue))} 파일에 두 번 이상 있습니다${where(issue)}.`, next: "중복된 줄을 하나로 정리하세요." }), ROW_INVALID: () => ({ message: `${issue.row ?? "?"}번째 줄${ref(issue)}의 값을 읽지 못했습니다. 칸의 형식이 맞는지 확인하세요.`, next: "숫자 칸에는 숫자만, 종류 칸에는 정해진 값만 쓰세요." }), ITEM_KEY_CHANGED: () => ({ message: `항목${ref(issue)}의 내부 키는 바꿀 수 없습니다${where(issue)}.`, next: "내부 키 칸은 내보낸 값 그대로 두세요." }), - DELETE_TARGET_NOT_FOUND: () => ({ message: `지우려는 것${ref(issue)}이(가) 추적표에 없습니다${where(issue)}.`, next: "삭제 줄의 종류와 ID를 확인하세요." }), + DELETE_TARGET_NOT_FOUND: () => ({ message: `${iga("지우려는 것" + ref(issue))} 추적표에 없습니다${where(issue)}.`, next: "삭제 줄의 종류와 ID를 확인하세요." }), DELETE_BLOCKED_BY_LINK: () => ({ message: `항목${ref(issue)}에는 아직 연결이 남아 있어 지울 수 없습니다.`, next: "연결 줄의 삭제 줄도 함께 넣거나, 항목 삭제를 빼세요." }), DELETE_BLOCKED_BY_RULE: () => ({ message: `기준${ref(issue)}에는 판정 규칙이 남아 있어 지울 수 없습니다.`, next: "규칙의 삭제 줄도 함께 넣거나, 기준 삭제를 빼세요." }), DELETE_BLOCKED_BY_RESULT: () => ({ message: `항목${ref(issue)}에는 결과가 남아 있어 지울 수 없습니다.`, next: "결과의 삭제 줄도 함께 넣거나, 삭제를 빼세요." }), - DELETE_AND_UPSERT_SAME_TARGET: () => ({ message: `${ref(issue)}을(를) 같은 파일에서 고치면서 지우려 합니다${where(issue)}.`, next: "둘 중 하나만 남기세요." }), + DELETE_AND_UPSERT_SAME_TARGET: () => ({ message: `${eulreul(ref(issue).trim())} 같은 파일에서 고치면서 지우려 합니다${where(issue)}.`, next: "둘 중 하나만 남기세요." }), DELETE_NOT_ALLOWED_IN_CREATE: () => ({ message: "'새로 만들기'에는 삭제 줄을 쓸 수 없습니다.", next: "삭제 줄을 빼거나 '기존 표 고치기'를 선택하세요." }), DELETE_ROW_INCOMPLETE: () => ({ message: `삭제 줄에 종류나 ID가 비어 있습니다${where(issue)}.`, next: "삭제 줄에는 종류와 ID를 모두 쓰세요." }), - EXCEL_ID_SUSPECTED: () => ({ message: `ID${ref(issue)}는 스프레드시트가 바꾼 것처럼 보입니다${where(issue)}. ${issue.detail}`, next: "원래 ID로 되돌리고, ID 칸을 '텍스트' 서식으로 바꾼 뒤 다시 저장하세요." }), + EXCEL_ID_SUSPECTED: () => ({ message: `${eunneun("ID" + ref(issue))} 스프레드시트가 바꾼 것처럼 보입니다${where(issue)}. ${issue.detail}`, next: "원래 ID로 되돌리고, ID 칸을 '텍스트' 서식으로 바꾼 뒤 다시 저장하세요." }), GRAPH_INVALID: () => ({ message: "반영하면 연결이 맞지 않는 곳이 생깁니다.", next: "연결 줄의 양쪽 ID와 종류가 추적표에 있는지 확인하세요." }), HEADER_MISSING_COLUMNS: () => ({ message: "파일의 머리 줄에 꼭 필요한 열이 빠져 있습니다.", next: "'CSV로 내보내기'로 받은 파일의 머리 줄을 지우지 말고 쓰세요." }), HEADER_DUPLICATE_COLUMN: () => ({ message: "머리 줄에 같은 열 이름이 두 번 있습니다.", next: "중복된 열을 지우세요." }), diff --git a/config/module-responsibility-budget.json b/config/module-responsibility-budget.json index a2b2aab..66cae64 100644 --- a/config/module-responsibility-budget.json +++ b/config/module-responsibility-budget.json @@ -320,7 +320,7 @@ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split" }, "src/thoth/application/services/critical_counter_search_coordinator.py": { - "baseline_lines": 868, + "baseline_lines": 835, "top_level_symbols": [ "CriticalCounterSearchExecution", "CriticalCounterSearchCoordinator" @@ -421,7 +421,7 @@ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split" }, "src/thoth/application/workflows/thread_cycle.py": { - "baseline_lines": 889, + "baseline_lines": 872, "top_level_symbols": [ "ThreadCycleCommand", "ThreadCycleResult", @@ -475,9 +475,7 @@ } }, "watched_long_functions": { - "src/thoth/application/services/acquisition_coordinator.py::execute_if_required": 209, - "src/thoth/application/services/critical_counter_search_coordinator.py::_execute_multi_wave": 380, - "src/thoth/application/workflows/thread_cycle.py::execute": 207, + "src/thoth/application/services/critical_counter_search_coordinator.py::_execute_multi_wave": 343, "src/thoth/apps/runtime.py::create_runtime": 548 } } diff --git a/docs/architecture/research-context-completion-contract.md b/docs/architecture/research-context-completion-contract.md index 16f3072..9ba42b3 100644 --- a/docs/architecture/research-context-completion-contract.md +++ b/docs/architecture/research-context-completion-contract.md @@ -82,13 +82,15 @@ reported as a model-call failure with no answer; the operation itself stays a pu Their JSON representation is decimal text, as with transport timeout controls; older numeric observations remain readable. Recording an interrupted dispatch preserves its diagnostic instead of failing while serializing the observation. -- **Automatic retry is a project setting, off by default.** `model/callSettings/read|update` +- **Automatic retry is a project setting, on by default (user decision 20261010-01).** `model/callSettings/read|update` (`auto_retry_interrupted_model_call`, digest-checked; the default is one line in `domain/model_call_settings.py`). When on, a cut-off, slowed, over-long or runaway call (not a 429, not an authentication refusal, not a format error, not a usage limit) is sent again as `retry_of_dispatch_id`, at most twice per call, waiting about 2 s and then 4 s (each shaken by up to - 25%). The transient-429 retry happens at most once and shares the limit of two. Usage counts every send. It is off because the server cannot say whether it already received a - request or whether the cut-off one stopped; the user continues with "이어서 조사" instead. + 25%). The transient-429 retry happens at most once and shares the limit of two. Usage counts every + send. An existing saved choice, including false, is preserved; only a project without a saved + choice uses the default. The server cannot say whether the cut-off request stopped, so retries + may repeat remote work. With the switch off, the user continues with "이어서 조사" instead. - **A judgement review is never reused.** `NON_REUSABLE_ROLES` (`research_stage_reuse.py`) keeps `REVIEW_ADJUDICATOR` out of resume: its conflict and gap decisions carry the requirement-set digest of the run that made them, so a reused one would be discarded as stale. diff --git a/scripts/hypothesis_v3_metrics.py b/scripts/hypothesis_v3_metrics.py index 81d6cac..dd49d92 100644 --- a/scripts/hypothesis_v3_metrics.py +++ b/scripts/hypothesis_v3_metrics.py @@ -12,23 +12,108 @@ `countable_tests` is the part of it that comes from the result alone: tests whose table names every hypothesis. Whether such a test also lands in the first group depends on its cost and executability, which the screen shows. + +unbased_prediction_rows counts table rows that give a prediction (not "모름") but name no +supporting span: predictions written from the hypothesis alone ("근거 없는 예측 행 수"). + +worst, best_worst and tests_better_than_one_vs_rest use the same rule as the screen's test order +(apps/web/src/components/testRanking.ts, outcomeClasses): the result classes a test can give are +the groups of hypotheses with the same label ("모름" stays in every class), and worst is the +size of the largest group. A test whose table does not name every hypothesis cannot be counted; +its worst is candidates - 1 (at least 1). candidates is the number of hypotheses. +tests_better_than_one_vs_rest counts tests whose worst is below candidates - 1, so a test that +splits the hypotheses more evenly than one against the rest. + +table_fill is printed when the result carries a hypothesis_table_fill record (v3 only): whether +the table-filling call was made (CALLED, SKIPPED_FULL, SKIPPED_BUDGET, FAILED), why not, how many +cells changed, and the share of cells still open (missing or 모름) before and after it. A cell is +a (test, hypothesis) pair, so these shares differ from unknown_ratio, which counts only rows. + +foreign_script_fields counts every readable text of the hypotheses and their tests (statement, +uncertainty, the list texts, refutation conditions, test procedure and expected texts, table +labels) that has Chinese characters or kana; a quotation from the material counts too, so a few +can be right. + +labels_with_hanja counts table rows whose label contains Chinese characters (hanja); a Korean +question should get Hangul labels. labels_with_kana counts rows whose label contains Japanese +kana. Labels are only counted, never changed. """ from __future__ import annotations import json +import re import sys from collections.abc import Mapping from pathlib import Path from typing import Any, cast UNKNOWN_LABEL = "모름" +_HAN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") +_KANA = re.compile(r"[\u3040-\u30fa\u30fc-\u30ff]") # not U+30FB, the katakana middle dot +READABLE_LISTS = ( + "missing_evidence", + "counterevidence_queries", + "predicted_observations", + "assumptions", + "refutation_conditions", +) +READABLE_TEST_TEXTS = ("procedure_candidate", "expected_if_true", "expected_if_alternative") + + +def readable_texts(hypothesis: Mapping[str, Any]) -> list[str]: + """Every text of a hypothesis and its tests that a person reads.""" + found = [str(hypothesis.get(key) or "") for key in ("statement", "uncertainty")] + for key in READABLE_LISTS: + found.extend(str(item) for item in cast("list[object]", hypothesis.get(key) or [])) + for test in _maps(hypothesis.get("discriminating_tests")): + found.extend(str(test.get(key) or "") for key in READABLE_TEST_TEXTS) + found.extend( + str(row.get("expected") or "") for row in _maps(test.get("expected_by_hypothesis")) + ) + return found def _normalized(label: str) -> str: return " ".join(label.split()).lower() +def _outcome_classes( + item: Mapping[str, Any], candidates: list[str], tester: str | None +) -> list[list[str]] | None: + """The candidates left under each possible result; None when the split cannot be counted.""" + if item.get("kind") != "TEST" or tester is None or tester not in candidates: + return None + labels = item.get("labels") + if labels is not None: + if not all(name in labels for name in candidates): + return None + unknown = [name for name in candidates if _normalized(labels[name]) == UNKNOWN_LABEL] + by_label: dict[str, list[str]] = {} + for name in candidates: + if name not in unknown: + by_label.setdefault(_normalized(labels[name]), []).append(name) + return ( + [unknown] if not by_label else [[*members, *unknown] for members in by_label.values()] + ) + table = item.get("predictions") + others = [name for name in candidates if name != tester] + if table is None or not all(name in table for name in others): + return None + return [ + [tester, *[name for name in others if table[name] == "WITH_TEST"]], + [name for name in others if table[name] == "WITH_ALTERNATIVE"], + ] + + +def worst_for_item(item: Mapping[str, Any], candidates: list[str]) -> tuple[int, bool]: + """(worst, counted): the most candidates left whichever result comes, as the screen counts.""" + classes = _outcome_classes(item, candidates, cast("str | None", item.get("hypothesisId"))) + if classes is None: + return max(1, len(candidates) - 1), False + return max(len(members) for members in classes), True + + def _maps(value: object) -> list[dict[str, Any]]: """The mapping items of a JSON list; anything else is an empty list.""" if not isinstance(value, list): @@ -53,6 +138,36 @@ def find_portfolio(document: object) -> dict[str, Any] | None: return None +def find_table_fill(document: object) -> dict[str, Any] | None: + """The table-filling record inside a result document (v3 only), or None when there is none.""" + paths = (("result",), ("current_result", "result"), ()) + for path in paths: + node = _mapping(document) + for key in path: + node = _mapping(node.get(key)) if node is not None else None + found = None if node is None else _mapping(node.get("hypothesis_table_fill")) + if found is not None: + return found + return None + + +def fill_report(record: Mapping[str, Any]) -> dict[str, Any]: + """What the table-filling call did: whether it was made, how many cells changed, and the share + of cells still open (missing or 모름) before and after it.""" + cells = int(record.get("cells") or 0) + before, after = int(record.get("open_before") or 0), int(record.get("open_after") or 0) + return { + "state": record.get("state"), + "reason_code": record.get("reason_code"), + "cells": cells, + "changed_cells": int(record.get("changed_cells") or 0), + "open_before": before, + "open_after": after, + "open_ratio_before": round(before / cells, 3) if cells else None, + "open_ratio_after": round(after / cells, 3) if cells else None, + } + + def measure(portfolio: Mapping[str, Any]) -> dict[str, Any]: hypotheses = _maps(portfolio.get("hypotheses")) ids = {str(item.get("hypothesis_id")) for item in hypotheses} @@ -61,6 +176,9 @@ def measure(portfolio: Mapping[str, Any]) -> dict[str, Any]: rows = [row for table in tables for row in table] unknown = [row for row in rows if _normalized(str(row.get("expected", ""))) == UNKNOWN_LABEL] with_basis = [row for row in rows if row.get("basis")] + unbased = [row for row in rows if row not in unknown and not row.get("basis")] + hanja_labels = [row for row in rows if _HAN.search(str(row.get("expected", "")))] + kana_labels = [row for row in rows if _KANA.search(str(row.get("expected", "")))] countable = [ table for table in tables @@ -74,6 +192,25 @@ def measure(portfolio: Mapping[str, Any]) -> dict[str, Any]: conditions = sum( len(cast("list[object]", item.get("refutation_conditions") or [])) for item in hypotheses ) + candidates = [str(item.get("hypothesis_id")) for item in hypotheses] + worst_by_test: list[dict[str, Any]] = [] + for item in hypotheses: + for test in _maps(item.get("discriminating_tests")): + rows_of = _maps(test.get("expected_by_hypothesis")) + labels = { + str(row.get("hypothesis_id")): str(row.get("expected", "")) for row in rows_of + } + worst, counted = worst_for_item( + { + "kind": "TEST", + "hypothesisId": str(item.get("hypothesis_id")), + "labels": labels if rows_of else None, + }, + candidates, + ) + worst_by_test.append( + {"test_id": test.get("test_id"), "worst": worst, "counted": counted} + ) return { "hypotheses": len(hypotheses), "tests": len(tests), @@ -84,6 +221,21 @@ def measure(portfolio: Mapping[str, Any]) -> dict[str, Any]: "unknown_ratio": round(len(unknown) / len(rows), 3) if rows else None, "refutation_conditions": conditions, "rows_with_basis_ratio": round(len(with_basis) / len(rows), 3) if rows else None, + "unbased_prediction_rows": len(unbased), + "labels_with_hanja": len(hanja_labels), + "labels_with_kana": len(kana_labels), + "foreign_script_fields": sum( + 1 + for item in hypotheses + for text in readable_texts(item) + if _HAN.search(text) or _KANA.search(text) + ), + "candidates": len(candidates), + "best_worst": min((entry["worst"] for entry in worst_by_test), default=None), + "tests_better_than_one_vs_rest": sum( + 1 for entry in worst_by_test if entry["worst"] < len(candidates) - 1 + ), + "worst_by_test": worst_by_test, } @@ -100,7 +252,13 @@ def main(argv: list[str]) -> int: code = 1 continue portfolio = find_portfolio(document) - report = {"file": name, **(measure(portfolio) if portfolio else {"error": "NO_PORTFOLIO"})} + report: dict[str, Any] = { + "file": name, + **(measure(portfolio) if portfolio else {"error": "NO_PORTFOLIO"}), + } + record = find_table_fill(document) + if record is not None: + report["table_fill"] = fill_report(record) print(json.dumps(report, ensure_ascii=False)) code = code or (1 if portfolio is None else 0) return code diff --git a/scripts/prepare_synthetic_radar_demo.py b/scripts/prepare_synthetic_radar_demo.py index cda8f84..4a1b2ef 100644 --- a/scripts/prepare_synthetic_radar_demo.py +++ b/scripts/prepare_synthetic_radar_demo.py @@ -6,17 +6,26 @@ python scripts/prepare_synthetic_radar_demo.py phase1 --api http://127.0.0.1:8861 python scripts/prepare_synthetic_radar_demo.py phase2 --api http://127.0.0.1:8861 -phase1 makes a project, connects the requirement, test-plan and dry-weather result files as -project materials (the same way a person uses "파일 자료 연결"), and writes the phase 1 trace CSV. -The CSV's "근거 위치" are the ids of the sentences THOTH read from the connected result file, so -choosing one in the trace table highlights that sentence. The person then imports the CSV on the -trace page ("새로 만들기"). +phase1 makes a project, connects the requirement, test-plan and dry-weather result files, and the +trial file the dry result names (its "trials_csv"), as project materials (the same way a person +uses "파일 자료 연결"), and writes the phase 1 trace CSV. The CSV's "근거 위치" are the ids of the +sentences THOTH read from the connected result file, so choosing one in the trace table highlights +that sentence. The person then imports the CSV on the trace page ("새로 만들기"). -phase2 connects the rain result file to the same project and writes the CSV that adds the rain -results ("기존 표 고치기"). It only writes a file; the import is done on the trace page. +phase2 connects the rain result file and its trial file to the same project and writes the CSV that +adds the rain results ("기존 표 고치기"). It only writes a file; the import is done on the trace +page. + +Only material that can be evidence is connected: the requirement, the test plan, the results and +the trial files they name. The manifest (file hashes), the source-lineage note and the JSON schema +describe the folder, not what was observed, so they are left out. The demo files are invented example data. This script sends them as they are, under their own names (.yaml files are read as plain text). It never starts a model run. + +A demo folder for another field says in its 00_MANIFEST_SYNTHETIC.yaml what is specific to it: +result_parts (the sections of a result file that hold a value, in order), requirement_file, +test_plan_file and version_label. A folder without those entries is the radar demo. """ from __future__ import annotations @@ -31,7 +40,7 @@ from datetime import datetime from decimal import Decimal from pathlib import Path -from typing import Any +from typing import Any, cast from uuid import uuid4 import yaml @@ -51,9 +60,11 @@ NOTICE = "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE" DEFAULT_DEMO = Path("examples/synthetic-radar-demo-v1") DEFAULT_OUT = Path("outputs/b5-demo/files") +MANIFEST_FILE = "00_MANIFEST_SYNTHETIC.yaml" REQUIREMENT_FILE = "10_REQUIREMENTS_SYNTHETIC.yaml" TEST_PLAN_FILE = "20_TEST_PLAN_SYNTHETIC.yaml" RESULT_PARTS = ("detection", "false_track") +VERSION_LABEL = "synthetic-radar-demo-v1" STATE_NAME = "state.json" # One call to the server: (path, body, headers) -> the decoded JSON answer. @@ -64,6 +75,36 @@ class DemoError(Exception): pass +@dataclass(frozen=True) +class DemoSpec: + """What differs between demo folders; the radar demo is the default.""" + + result_parts: tuple[str, ...] = RESULT_PARTS + requirement_file: str = REQUIREMENT_FILE + test_plan_file: str = TEST_PLAN_FILE + version_label: str = VERSION_LABEL + + +def demo_spec(demo: Path) -> DemoSpec: + """The demo's own names from its manifest; anything the manifest does not say is the radar's.""" + manifest_path = demo / MANIFEST_FILE + loaded: Any = ( + yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if manifest_path.exists() + else None + ) + manifest = cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {} + default = DemoSpec() + return DemoSpec( + result_parts=tuple( + str(part) for part in manifest.get("result_parts", default.result_parts) + ), + requirement_file=str(manifest.get("requirement_file", default.requirement_file)), + test_plan_file=str(manifest.get("test_plan_file", default.test_plan_file)), + version_label=str(manifest.get("version_label", default.version_label)), + ) + + def http_transport(base: str) -> Transport: def send(path: str, body: bytes, headers: dict[str, str]) -> dict[str, Any]: request = urllib.request.Request(base.rstrip("/") + path, data=body, headers=headers) @@ -120,7 +161,9 @@ def stage(self, project_id: str, name: str, data: bytes) -> dict[str, Any]: # --- connecting the demo files as project materials ------------------------------------------ -def connect_material(api: Api, project_id: str, path: Path) -> str: +def connect_material( + api: Api, project_id: str, path: Path, version_label: str = VERSION_LABEL +) -> str: """Connect one demo file the way the file panel does; returns the artifact id.""" staged = api.stage(project_id, path.name, path.read_bytes()) connected = api.rpc( @@ -133,20 +176,30 @@ def connect_material(api: Api, project_id: str, path: Path) -> str: "cutoff_state": "ELIGIBLE", "security_class": "INTERNAL", "resource_scope": {"owner_kind": "PROJECT", "visibility": "PROJECT_SHARED"}, - "version_label": "synthetic-radar-demo-v1", + "version_label": version_label, }, ) return str(connected["artifact"]["artifact_id"]) -def value_lines(text: str) -> dict[str, int]: - """The line of the "value:" entry under detection and under false_track (first line is 1).""" +def connect_result(api: Api, project_id: str, demo: Path, path: Path) -> str: + """Connect a result file and the trial file it names; returns the result file's artifact id.""" + label = demo_spec(demo).version_label + artifact = connect_material(api, project_id, path, label) + trials = _yaml(path).get("trials_csv") + if trials: + connect_material(api, project_id, demo / str(trials), label) + return artifact + + +def value_lines(text: str, parts: tuple[str, ...] = RESULT_PARTS) -> dict[str, int]: + """The line of the "value:" entry under each result part (first line is 1).""" found: dict[str, int] = {} section: str | None = None for number, line in enumerate(text.splitlines(), start=1): if line and not line[0].isspace() and line.rstrip().endswith(":"): section = line.rstrip()[:-1] - elif section in RESULT_PARTS and line.strip().startswith("value:"): + elif section in parts and line.strip().startswith("value:"): found.setdefault(section, number) return found @@ -161,14 +214,16 @@ def sentence_ids(api: Api, project_id: str, artifact_id: str) -> dict[int, str]: } -def result_sentence_ids(api: Api, project_id: str, artifact_id: str, path: Path) -> dict[str, str]: - """For one result file: {"detection": sentence id, "false_track": sentence id}.""" - lines = value_lines(path.read_text(encoding="utf-8")) +def result_sentence_ids( + api: Api, project_id: str, artifact_id: str, path: Path, parts: tuple[str, ...] = RESULT_PARTS +) -> dict[str, str]: + """For one result file: {part: sentence id of its value line}, for each result part.""" + lines = value_lines(path.read_text(encoding="utf-8"), parts) sentences = sentence_ids(api, project_id, artifact_id) - missing = [part for part in RESULT_PARTS if lines.get(part) not in sentences] + missing = [part for part in parts if lines.get(part) not in sentences] if missing: raise DemoError(f"{path.name}: no connected sentence for {', '.join(missing)}") - return {part: sentences[lines[part]] for part in RESULT_PARTS} + return {part: sentences[lines[part]] for part in parts} # --- the trace set ----------------------------------------------------------------------------- @@ -185,11 +240,12 @@ def result_files(demo: Path) -> list[Path]: def build_trace_set(demo: Path, phase: int, spans: dict[str, dict[str, str]]) -> TraceSet: """The demo as a trace set. - spans: result file name -> {"detection" / "false_track": sentence id of its value line}. + spans: result file name -> {result part: sentence id of its value line}. """ + spec = demo_spec(demo) marked = {"synthetic_notice": NOTICE} - requirement = _yaml(demo / REQUIREMENT_FILE)["requirement"] - cases = _yaml(demo / TEST_PLAN_FILE)["test_cases"] + requirement = _yaml(demo / spec.requirement_file)["requirement"] + cases = _yaml(demo / spec.test_plan_file)["test_cases"] items = [ TraceItem( item_id=requirement["id"], @@ -258,7 +314,7 @@ def build_trace_set(demo: Path, phase: int, spans: dict[str, dict[str, str]]) -> continue if path.name not in spans: raise DemoError(f"{path.name}: its sentences are not connected yet") - for part in RESULT_PARTS: + for part in spec.result_parts: body = data[part] rid = f"SYN-RES-{body['criterion']}" items.append( @@ -279,8 +335,8 @@ def build_trace_set(demo: Path, phase: int, spans: dict[str, dict[str, str]]) -> condition=data["condition"], value=Decimal(body["value"]), unit=body["unit"], - numerator=body["numerator"], - denominator=body["denominator"], + numerator=body.get("numerator"), + denominator=body.get("denominator"), observed_at=datetime.fromisoformat(data["observed_at"]), source_span_refs=(spans[path.name][part],), ) @@ -354,11 +410,14 @@ def prepare_phase1(api: Api, demo: Path, out: Path, name: str) -> Prepared: "overlay": "general-rnd", }, ) - connect_material(api, project_id, demo / REQUIREMENT_FILE) - connect_material(api, project_id, demo / TEST_PLAN_FILE) + spec = demo_spec(demo) + connect_material(api, project_id, demo / spec.requirement_file, spec.version_label) + connect_material(api, project_id, demo / spec.test_plan_file, spec.version_label) dry = next(path for path in result_files(demo) if _yaml(path)["available_from_phase"] == 1) spans = { - dry.name: result_sentence_ids(api, project_id, connect_material(api, project_id, dry), dry) + dry.name: result_sentence_ids( + api, project_id, connect_result(api, project_id, demo, dry), dry, spec.result_parts + ) } text = phase1_csv(demo, spans) preview = _preview(api, project_id, "CREATE", text) @@ -379,8 +438,10 @@ def prepare_phase2(api: Api, demo: Path, out: Path) -> Prepared: "the project's trace is not the phase 1 import (import phase 1 first, unchanged)" ) rain = next(path for path in result_files(demo) if _yaml(path)["available_from_phase"] == 2) - artifact = connect_material(api, project_id, rain) - spans[rain.name] = result_sentence_ids(api, project_id, artifact, rain) + artifact = connect_result(api, project_id, demo, rain) + spans[rain.name] = result_sentence_ids( + api, project_id, artifact, rain, demo_spec(demo).result_parts + ) text = phase2_csv(demo, spans, current["set_digest"]) preview = _preview(api, project_id, "UPDATE", text) csv_path = out / "trace_phase2_rain_update.csv" diff --git a/scripts/update_source_manifest.py b/scripts/update_source_manifest.py index e7ce9c0..cf726b6 100644 --- a/scripts/update_source_manifest.py +++ b/scripts/update_source_manifest.py @@ -1,8 +1,8 @@ """Rebuild or check SOURCE_MANIFEST.json, the byte inventory of the public source files. Run `python scripts/update_source_manifest.py` after the last file change and before opening a -public pull request, then commit the result. `--check` writes nothing: it lists what differs and -exits 1 when the list is stale. +public pull request, then commit the result. `--check` leaves the repository unchanged: it lists +what differs and exits 1 when the list is stale. Git clean conversion runs in disposable storage. The list names every file that is tracked (or untracked but not ignored) except the list itself, whose hash would depend on its own bytes. This is the same rule the current list follows. The @@ -13,12 +13,16 @@ import argparse import hashlib +import io import json +import os import subprocess import sys from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Protocol, TypedDict, cast MANIFEST_NAME = "SOURCE_MANIFEST.json" EXCLUDED_PATHS = frozenset({MANIFEST_NAME}) @@ -46,6 +50,16 @@ Manifest = dict[str, object] +class FileRow(TypedDict): + path: str + sha256: str + bytes: int + + +class _Reconfigurable(Protocol): + def reconfigure(self, *, encoding: str) -> None: ... + + @dataclass(frozen=True) class Difference: changed: tuple[str, ...] @@ -70,8 +84,87 @@ def list_public_paths(root: Path) -> list[str]: ) -def _row(root: Path, relative: str) -> dict[str, object]: - data = (root / relative).read_bytes() +def read_source_bytes(root: Path, paths: list[str]) -> dict[str, bytes]: + """Read working files as Git add would store them, or raw bytes in a source export. + + hash-object does not load the index and differs from add for historical text=auto CRLF + blobs. Use add with a disposable index and object directory instead. The real index, + objects, refs and working files are never written. Configured clean filters still run. + """ + if not paths: + return {} + root = root.resolve() + if not (root / ".git").exists(): + return {relative: (root / relative).read_bytes() for relative in paths} + + def git(*args: str, data: bytes | None = None, env: dict[str, str] | None = None) -> bytes: + return subprocess.run( + ["git", "-C", str(root), *args], + input=data, + capture_output=True, + check=True, + env=env, + ).stdout + + # Preserve index contents (including the CRLF exception), but not stat caches or flags: + # every requested path must be read again, including unstaged and untracked content. + entries = git("ls-files", "--stage", "-z") + objects = git("rev-parse", "--path-format=absolute", "--git-path", "objects") + with TemporaryDirectory(prefix="thoth-manifest-") as temporary: + scratch = Path(temporary) + (scratch / "objects").mkdir() + (scratch / "hooks").mkdir() + alternates = json.dumps(objects.decode("utf-8").strip(), ensure_ascii=False) + if inherited := os.environ.get("GIT_ALTERNATE_OBJECT_DIRECTORIES"): + alternates += os.pathsep + inherited + env = { + **os.environ, + "GIT_INDEX_FILE": str(scratch / "index"), + "GIT_OBJECT_DIRECTORY": str(scratch / "objects"), + "GIT_ALTERNATE_OBJECT_DIRECTORIES": alternates, + "GIT_LITERAL_PATHSPECS": "1", + "GIT_OPTIONAL_LOCKS": "0", + } + options = ( + "-c", + "core.splitIndex=false", + "-c", + "core.fsmonitor=false", + "-c", + f"core.hooksPath={scratch / 'hooks'}", + ) + git(*options, "update-index", "-z", "--index-info", data=entries, env=env) + git( + *options, + "add", + "--sparse", + "--pathspec-from-file=-", + "--pathspec-file-nul", + data=b"\0".join(path.encode("utf-8") for path in paths) + b"\0", + env=env, + ) + staged: dict[str, bytes] = {} + for entry in git(*options, "ls-files", "--stage", "-z", env=env).split(b"\0"): + if entry: + metadata, name = entry.split(b"\t", 1) + _, oid, stage = metadata.split() + if stage == b"0": + staged[name.decode("utf-8")] = oid + oids = [staged[path] for path in paths] + output = io.BytesIO(git("cat-file", "--batch", data=b"\n".join(oids) + b"\n", env=env)) + result: dict[str, bytes] = {} + for path, oid in zip(paths, oids, strict=True): + actual, kind, size = output.readline().split() + if actual != oid or kind != b"blob": + raise ValueError(f"Git did not return a source blob: {path}") + data = output.read(int(size)) + if len(data) != int(size) or output.read(1) != b"\n": + raise ValueError(f"Git returned an incomplete source blob: {path}") + result[path] = data + return result + + +def _row(relative: str, data: bytes) -> FileRow: return {"path": relative, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)} @@ -83,14 +176,17 @@ def build_manifest( root: Path, paths: list[str], previous: Manifest, *, created_at: str ) -> Manifest: """A new list over `paths`; every header field of `previous` stays as it was.""" - if set(previous) != HEADER_FIELDS: + if frozenset(previous) != HEADER_FIELDS: raise ValueError("the previous list has a different header than the checker accepts") built: Manifest = {} for key, value in previous.items(): if key == "created_at": built[key] = created_at elif key == "files": - built[key] = [_row(root, relative) for relative in _listed(paths)] + built[key] = [ + _row(relative, data) + for relative, data in read_source_bytes(root, _listed(paths)).items() + ] else: built[key] = value return built @@ -99,12 +195,13 @@ def build_manifest( def compare(root: Path, manifest: Manifest, paths: list[str]) -> Difference: files = manifest["files"] assert isinstance(files, list) - rows: dict[str, dict[str, object]] = {item["path"]: item for item in files} + rows = {item["path"]: item for item in cast(list[FileRow], files)} current = set(_listed(paths)) + contents = read_source_bytes(root, sorted(rows.keys() & current)) changed = sorted( relative for relative in rows.keys() & current - if {k: v for k, v in _row(root, relative).items() if k != "path"} + if {k: v for k, v in _row(relative, contents[relative]).items() if k != "path"} != {k: v for k, v in rows[relative].items() if k != "path"} ) return Difference( @@ -133,7 +230,7 @@ def format_report(difference: Difference) -> str: def main(argv: list[str] | None = None, root: Path | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser = argparse.ArgumentParser(description=(__doc__ or "Source manifest").splitlines()[0]) parser.add_argument("--check", action="store_true", help="compare only; exit 1 if stale") options = parser.parse_args(argv) root = (root or Path(__file__).resolve().parents[1]).resolve() @@ -142,7 +239,7 @@ def main(argv: list[str] | None = None, root: Path | None = None) -> int: paths = list_public_paths(root) stdout = sys.stdout if hasattr(stdout, "reconfigure"): - stdout.reconfigure(encoding="utf-8") + cast(_Reconfigurable, stdout).reconfigure(encoding="utf-8") if options.check: difference = compare(root, previous, paths) if difference.clean: @@ -155,7 +252,7 @@ def main(argv: list[str] | None = None, root: Path | None = None) -> int: path.write_bytes(render(built)) files = built["files"] assert isinstance(files, list) - print(f"{MANIFEST_NAME} rebuilt: {len(files)} files.") + print(f"{MANIFEST_NAME} rebuilt: {len(cast(list[object], files))} files.") return 0 diff --git a/src/thoth/adapters/models/codex_http.py b/src/thoth/adapters/models/codex_http.py index 6a98056..ad66ac1 100644 --- a/src/thoth/adapters/models/codex_http.py +++ b/src/thoth/adapters/models/codex_http.py @@ -261,7 +261,7 @@ async def dispatch(self, request: PreparedModelDispatch) -> ModelTransportReply: ) if remaining is not None and remaining <= 0: raise ModelExecutionHold("OAUTH_TRANSPORT_DEADLINE_REMOTE_STOP_UNKNOWN") - stats = ReceiveStats() + stats = ReceiveStats(sensitive_values=(settings.access_token, settings.account_id)) timeouts = (request.transport_timeouts or TransportTimeouts()).bounded( request.timeout_seconds ) diff --git a/src/thoth/adapters/models/codex_oauth.py b/src/thoth/adapters/models/codex_oauth.py index e0f75b2..ff9650a 100644 --- a/src/thoth/adapters/models/codex_oauth.py +++ b/src/thoth/adapters/models/codex_oauth.py @@ -9,7 +9,11 @@ from pydantic import BaseModel, ValidationError -from thoth.adapters.models.hypothesis_contract_text import hypothesis_contract_addendum +from thoth.adapters.models.hypothesis_contract_text import ( + hypothesis_contract_addendum, + hypothesis_opening, + table_filler_contract, +) from thoth.adapters.models.reference_schema import ( apply_hypothesis_review_contract, constrain_span_references, @@ -486,9 +490,14 @@ def role_contract(role: str, prompt_version: str = "") -> str: ) if role == "HYPOTHESIS_GENERATOR": return ( - "Create only justified source-grounded hypotheses. Zero or one is allowed when " - "alternatives_considered, next_checks and uncertainty_reserve explain the limitation. " - "Causal locus may be null for predictive or exploratory intent. Drafts may have gaps. " + hypothesis_opening( + prompt_version, + "Create only justified source-grounded hypotheses. Zero or one is allowed when " + "alternatives_considered, next_checks and uncertainty_reserve explain the " + "limitation. ", + ) + + "Causal locus may be null for predictive or exploratory intent. " + "Drafts may have gaps. " "Every hypothesis needs support refs or explicit missing evidence, a " "counterevidence query, predicted observations, and a discriminating test. Use only " "provided span IDs and copy input_head_set_digest exactly. When previous_portfolio " @@ -510,6 +519,8 @@ def role_contract(role: str, prompt_version: str = "") -> str: "and discriminating_tests procedure/expected text" ) ) + if role == "HYPOTHESIS_TABLE_FILLER": + return table_filler_contract() + _user_visible_language_fields("expected labels") if role == "ACTION_PLANNER": return ( "Create an ActionPlanDraft with at least three materially different action families. " @@ -558,8 +569,9 @@ def _user_visible_language_fields(fields: str) -> str: return ( " User-facing fields (" + fields - + ") follow the question language. Do not translate identifiers, " - "quoted evidence, or numeric/table citations." + + ") follow the question language: for a Korean question write them in Hangul and " + "do not use Chinese characters or kana that the question or the quoted evidence does " + "not contain. Do not translate identifiers, quoted evidence, or numeric/table citations." ) diff --git a/src/thoth/adapters/models/hypothesis_contract_text.py b/src/thoth/adapters/models/hypothesis_contract_text.py index 148b61e..ec6ed91 100644 --- a/src/thoth/adapters/models/hypothesis_contract_text.py +++ b/src/thoth/adapters/models/hypothesis_contract_text.py @@ -2,24 +2,130 @@ from __future__ import annotations -V3_VERSION = "hypothesis_portfolio.v3" +V3_VERSION = "hypothesis_portfolio.v3.3" _V3 = ( " CONTRACT_V3. For every hypothesis prefer discriminating tests that tell it apart from the " "other hypotheses of this portfolio. For each discriminating test fill expected_by_hypothesis " - "with one item per hypothesis_id of this portfolio: expected is the result that hypothesis " - "predicts for the test, as a short label of at most 80 characters, worded the same for the " - "same result; write expected as 모름 when the supplied evidence gives no basis for that " - "hypothesis, or leave the hypothesis out, and never guess. basis lists only supplied span " - "IDs that support that expectation and may be empty. Use only hypothesis_id values that " - "appear in this portfolio. For every hypothesis fill refutation_conditions with up to five " - "short sentences, each saying which result would make you drop that hypothesis (if this " - "result appears, drop it); use an empty list when none can be justified. These labels and " - "sentences follow the question language like the other user-facing fields. Add no " + "with one item per hypothesis_id of this portfolio, whichever hypothesis the test was " + "designed for; never leave a hypothesis out, because a test is there to tell the hypotheses " + "of this portfolio apart. expected is the result that would logically follow for the test if " + "that hypothesis were true, as a short label of at most 80 characters, worded the same for " + "the same result: when two hypotheses predict the same result, give them the same label for " + "both. For a hypothesis the test was not designed for, this is usually that the result the " + "designed hypothesis predicts does not appear; write it consistently with " + "expected_if_alternative. Write the prediction even when no supplied evidence supports it; " + "it is derived from the hypothesis and is not a claim about the data. Each expected assumes " + "that hypothesis is the explanation and the other hypotheses of this portfolio are not; write " + "the result that follows under that assumption, even if another cause could also be present. " + "Write expected as 모름 " + "only when, even if that hypothesis were true, the result of this test cannot be fixed " + "logically; that the test was designed for another hypothesis is never a reason for 모름. " + "If a hypothesis's result would depend on a condition the test leaves open, so that you " + "would have to write 모름, choose a version of the test that holds that condition fixed or " + "measures it as well, so that every hypothesis fixes the result; a test that changes a " + "setting needs this most, so say on which inputs it is run and what is measured alongside. " + "Choose the tests so that every two hypotheses of this portfolio get different expected " + "labels on at least one test; when that cannot be justified, do not force it: give the two " + "the same label, which shows that the test does not separate them. This goal is about " + "which tests to choose, not about which hypotheses to keep: keep every hypothesis you " + "would have proposed without this contract, and never reduce their number or move one to " + "alternatives_considered to make the table easier to fill. If no test can justifiably " + "separate two hypotheses, keep both and give them the same label. The hypotheses of this " + "portfolio are alternatives to one another: if one is the explanation, the others are not. " + "When a hypothesis is only a path or stage through which another hypothesis's cause could " + "act, rewrite it as a cause that arises on its own, whatever the other causes are, so that " + "the two are real alternatives; rewrite it, never drop or merge it. Besides tests that " + "confirm a single hypothesis, include at least one test that splits the hypotheses into " + "groups of similar size when that can be justified, because such a test leaves the fewest " + "candidates whichever result comes; do not force one. " + "basis lists supplied span IDs only when they support that prediction; otherwise basis is an " + "empty list. Use only hypothesis_id values that appear in this portfolio. " + "Illustration of the form only, from another domain; never copy its wording or content. " + "A request is slow; H1 cache misconfiguration, H2 database lock contention, H3 network " + "latency, H4 disk degradation. Test for H1 (repeat the request with the cache disabled): " + "H1 becomes fast; H2 stays slow; H3 stays slow; H4 stays slow. Test that splits two and " + "two (repeat from a replica in another region): H1 stays slow; H2 stays slow; H3 becomes " + "fast; H4 becomes fast. It leaves at most two candidates whichever way it goes, while the " + "first can leave three, so it narrows the candidates more. Test for H4 (repeat with a " + "fresh local disk): H1 stays slow; H2 stays slow; H3 stays slow; H4 becomes fast. " + "Together every pair is separated. 모름 appears nowhere because each hypothesis fixes " + "the result of every test. " + "Write every label and sentence in the language of the question, even though this " + "illustration is in English; for a Korean question write in Hangul only and use no " + "Chinese characters (hanja) or kana. For every hypothesis fill refutation_conditions with " + "up to five short sentences, each saying which result would make you drop that hypothesis " + "(if this result appears, drop it); use an empty list when none can be justified. These " + "labels and sentences follow the question language like the other user-facing fields. Add no " "probability, score or ranking, and do not re-judge or change any verdict." ) +# In v3 the generator's base contract opens with this instead of "Create only justified +# source-grounded hypotheses. Zero or one is allowed ...": a hypothesis is a candidate to check, +# and thin evidence is a reason to say what is missing, never a reason to leave it out. +_V3_OPENING = ( + "Create the candidate causes to check, grounded in the supplied sources where they allow. " + "A hypothesis is a candidate to examine, not a claim: when the evidence is thin, still " + "offer every plausible candidate as a DRAFT hypothesis, usually three to five, and write " + "what is missing in that hypothesis's missing_evidence. Thin or missing evidence is never " + "a reason to leave a hypothesis out. Zero or one hypothesis is allowed only when at most " + "one plausible candidate exists, and then alternatives_considered, next_checks and " + "uncertainty_reserve must explain the limitation. Put into alternatives_considered only " + "candidates that make no sense, fall outside the question, or duplicate another " + "hypothesis. " +) + + +def hypothesis_opening(prompt_version: str, default: str) -> str: + """The generator's opening sentences: the v3 text for v3, the given text otherwise.""" + return _V3_OPENING if prompt_version == V3_VERSION else default + + def hypothesis_contract_addendum(prompt_version: str) -> str: """The extra text for this prompt version; empty for v2 and for every other version.""" return _V3 if prompt_version == V3_VERSION else "" + + +# The table-filling call: a second, narrow call after the generator wrote the portfolio. It writes +# only the cells the generator left empty or called 모름, so each one gets the attention a cell +# written beside a whole portfolio does not. +_TABLE_FILLER = ( + "Fill only the open cells of the expected-result tables of this portfolio. table_filler lists " + "the hypotheses and, for each test, the hypothesis it was designed for (designed_for), its " + "procedure, expected_if_true, expected_if_alternative, the current table, and " + "open_hypothesis_ids: the hypotheses whose cell is empty or says 모름. For every test that " + "has open_hypothesis_ids return one tests item with that designed_for and test_id and one " + "row for each open hypothesis_id. Add no test and no hypothesis and change no sentence or " + "existing cell. A row's expected is the result that would logically follow for this test if " + "that hypothesis were true, as a short label of at most 80 characters. Write it even when no " + "supplied evidence supports it: it is derived from the hypothesis and is not a claim about " + "the data. For a hypothesis the test was not designed for, this is usually that the result " + "the designed hypothesis predicts does not appear; write it consistently with " + "expected_if_alternative. Each row assumes that hypothesis is the explanation and the other " + "hypotheses of this portfolio are not; write the result that follows under that assumption, " + "even if another cause could also be present. If the designed hypothesis itself is open, its " + "row is the result this test predicts if that hypothesis is true (expected_if_true). " + "Write expected as 모름 only when, even if that hypothesis were " + "true, the result of this test cannot be fixed logically, for example when the hypothesis " + "interacts with the test condition so that either result may appear; that the test was " + "designed for another hypothesis is never a reason for 모름. When you write 모름, also write " + "unknown_reason: one sentence, in the language of the question, saying under which condition " + "the result of this test is left undetermined even if that hypothesis were true; use null for " + "unknown_reason with any other answer. When a hypothesis predicts a " + "result that a label already in this test's table names, reuse that label exactly and never " + "reword it: another wording would make two hypotheses look different when they predict the " + "same result. basis lists supplied span IDs only when they support the prediction; " + "otherwise it is an empty list. Use only hypothesis_id and test_id values that appear in " + "table_filler. Illustration of the form only, from another domain; never copy its wording " + "or content. Test t1 (repeat the request with the cache disabled) was designed for H1 and " + "its table says H1 becomes fast; open: H2, H3. H2 and H3 are not the designed hypothesis, " + "so the result H1 predicts does not appear: both rows say stays slow, worded the same. " + "Write every label in the language of the question, even though this illustration is in " + "English; for a Korean question write in Hangul only and use no Chinese characters (hanja) " + "or kana. Add no probability, score or ranking, and do not re-judge or change any verdict." +) + + +def table_filler_contract() -> str: + return _TABLE_FILLER diff --git a/src/thoth/adapters/models/receive_stats.py b/src/thoth/adapters/models/receive_stats.py index 45ab2ef..7536394 100644 --- a/src/thoth/adapters/models/receive_stats.py +++ b/src/thoth/adapters/models/receive_stats.py @@ -27,6 +27,7 @@ def _observed_decimal(value: float | None) -> Decimal | None: @dataclass class ReceiveStats: + sensitive_values: tuple[str, ...] = field(default=(), repr=False, kw_only=True) received_bytes: int = 0 visible_output_bytes: int = 0 frame_counts: dict[str, int] = field(default_factory=dict) @@ -50,7 +51,7 @@ class ReceiveStats: def capture_response(self, response: httpx.Response) -> None: try: - self.diagnostic_fields.update(response_detail(response)) + self.diagnostic_fields.update(response_detail(response, self.sensitive_values)) except Exception: self.diagnostic_unavailable = True @@ -66,7 +67,7 @@ def capture_httpx_exception(self, error: httpx.HTTPError) -> None: # primary exception detail without moving the already frozen receive time. self.transport_diagnostic = None try: - self.diagnostic_fields.update(exception_detail(error)) + self.diagnostic_fields.update(exception_detail(error, self.sensitive_values)) except Exception: self.diagnostic_unavailable = True diff --git a/src/thoth/adapters/models/transport_diagnostic.py b/src/thoth/adapters/models/transport_diagnostic.py index a5b9e94..53b70ab 100644 --- a/src/thoth/adapters/models/transport_diagnostic.py +++ b/src/thoth/adapters/models/transport_diagnostic.py @@ -1,9 +1,11 @@ -"""Local, bounded normalization. Never stringify an exception or persist header values.""" +"""Bounded diagnostics: validated correlation headers and conservatively redacted excerpts.""" +import re import ssl import httpx +from thoth.adapters.models.transport_message import safe_exception_prefix from thoth.domain.model_dispatch import TransportDiagnostic @@ -28,7 +30,9 @@ def _next(error: BaseException) -> object: return error.__cause__ if error.__cause__ is not None else error.__context__ -def exception_detail(error: httpx.HTTPError) -> dict[str, object]: +def exception_detail( + error: httpx.HTTPError, sensitive_values: tuple[str, ...] = () +) -> dict[str, object]: kind = type(error) name = kind.__name__ verified = ( @@ -38,6 +42,8 @@ def exception_detail(error: httpx.HTTPError) -> dict[str, object]: and len(name) <= 64 ) detail: dict[str, object] = {"httpx_error_type": name if verified else "UNKNOWN"} + if verified and (message := safe_exception_prefix(error, sensitive_values)) is not None: + detail["error_message_prefix"] = message node = _next(error) if node is None: return detail @@ -69,7 +75,9 @@ def exception_detail(error: httpx.HTTPError) -> dict[str, object]: return detail -def response_detail(response: httpx.Response) -> dict[str, object]: +def response_detail( + response: httpx.Response, sensitive_values: tuple[str, ...] = () +) -> dict[str, object]: version = response.http_version raw_encoding = response.headers.get("content-encoding") if raw_encoding is None: @@ -94,11 +102,25 @@ def response_detail(response: httpx.Response) -> dict[str, object]: ) else: framing = "CONTENT_LENGTH" if has_length else "ABSENT" - return { + detail: dict[str, object] = { "http_version": version if version in {"HTTP/1.0", "HTTP/1.1", "HTTP/2"} else "UNKNOWN", "content_encoding": encoding, "body_framing": framing, } + for header, field, limit, pattern in ( + ("x-oai-request-id", "x_oai_request_id", 128, r"[A-Za-z0-9][A-Za-z0-9_-]*"), + ("cf-ray", "cf_ray", 20, r"[0-9a-fA-F]{16}(?:-[A-Z]{3})?"), + ): + value = response.headers.get(header) + if ( + value is not None + and len(value) <= limit + and re.fullmatch(pattern, value) is not None + and not any(secret and secret in value for secret in sensitive_values) + and not value.lower().startswith(("sk-", "bearer", "basic")) + ): + detail[field] = value + return detail def build_diagnostic(fields: dict[str, object], unavailable: bool) -> TransportDiagnostic: diff --git a/src/thoth/adapters/models/transport_message.py b/src/thoth/adapters/models/transport_message.py new file mode 100644 index 0000000..8e24098 --- /dev/null +++ b/src/thoth/adapters/models/transport_message.py @@ -0,0 +1,152 @@ +"""Conservative exception excerpts: retain transport vocabulary, never arbitrary payload text.""" + +import re + +_WORDS = frozenset( + [ + "peer", + "closed", + "connection", + "without", + "sending", + "complete", + "message", + "body", + "incomplete", + "chunked", + "read", + "server", + "disconnected", + "reset", + "by", + "remote", + "protocol", + "error", + "local", + "network", + "transport", + "stream", + "unexpected", + "end", + "of", + "file", + "eof", + "received", + "receiving", + "send", + "sending", + "response", + "request", + "failed", + "failure", + "timed", + "out", + "timeout", + "connect", + "connecting", + "broken", + "pipe", + "aborted", + "refused", + "terminated", + "invalid", + "malformed", + "chunk", + "header", + "headers", + "encoding", + "decoding", + "compression", + "compressed", + "data", + "decompressing", + "content", + "length", + "expected", + "bytes", + "byte", + "read", + "write", + "socket", + "ssl", + "tls", + "certificate", + "verify", + "verification", + "handshake", + "alert", + "reason", + "the", + "a", + "an", + "is", + "was", + "has", + "been", + "not", + "no", + "and", + "or", + "in", + "on", + "at", + "from", + "to", + "for", + "with", + "during", + "while", + "before", + "after", + "exceeded", + "maximum", + "allowed", + "connectionreseterror", + "remoteprotocolerror", + "readerror", + "writeerror", + "connecterror", + "readtimeout", + "http", + "https", + "httpx", + "httpcore", + "h11", + ] +) +_UNSAFE_SUFFIX = re.compile( + r"(?i)\b(?:authorization|proxy-authorization|cookie|set-cookie|bearer|basic|" + r"[\w-]*token[\w-]*|api[-_ ]?key|password|secret|credentials|headers|payload)\b" + r"|\b[a-z][a-z0-9+.-]*://|[\"'{}<>]" +) +_TOKEN = re.compile(r"[A-Za-z0-9_./+=-]+|[^\x20-\x7e]+") + + +def safe_exception_prefix(error: BaseException, sensitive_values: tuple[str, ...]) -> str | None: + """Keep the first 256 sanitized characters; unknown words/numbers are redacted too. + + Process known credentials before taking a prefix, so truncation never exposes part of + a credential. Stop at structured/quoted data or authentication context. The remaining + allowlist deliberately loses unfamiliar prose instead of guessing whether it is secret. + """ + try: + message = str(error) + except Exception: + return None + for secret in sorted(set(sensitive_values), key=len, reverse=True): + if secret: + message = message.replace(secret, "[REDACTED]") + if len(message) > 4096: + # Drop the boundary token completely rather than accepting its truncated prefix. + message = message[:4096].rpartition(" ")[0] + " [REDACTED]" + unsafe = _UNSAFE_SUFFIX.search(message) + if unsafe is not None: + message = message[: unsafe.start()] + "[REDACTED]" + message = _TOKEN.sub( + lambda match: match.group() if match.group().lower() in _WORDS else "[REDACTED]", + message, + ) + message = re.sub( + r"(?:\[\[REDACTED\]\]|\[REDACTED\])(?:\s*\[REDACTED\])*", "[REDACTED]", message + ) + return message.strip()[:256] or None diff --git a/src/thoth/adapters/parsers/csv_parser.py b/src/thoth/adapters/parsers/csv_parser.py index d89ab1f..5656140 100644 --- a/src/thoth/adapters/parsers/csv_parser.py +++ b/src/thoth/adapters/parsers/csv_parser.py @@ -13,31 +13,82 @@ from thoth.domain.enums import StructuralNodeKind +def _is_number(value: str) -> bool: + try: + float(value.strip().replace(",", "")) + except ValueError: + return False + return True + + +def _is_header(row: list[str]) -> bool: + """The first row names the columns when it holds words only (no cell is a number).""" + cells = [value.strip() for value in row if value.strip()] + return bool(cells) and not any(_is_number(value) for value in cells) + + +def _row_range(row_number: int, width: int) -> str: + first = f"R{row_number}C1" + return first if width <= 1 else f"{first}:R{row_number}C{width}" + + +def _column(names: list[str] | None, index: int) -> str: + named = names[index] if names is not None and index < len(names) else "" + return named or f"col{index + 1}" + + class CsvParser: + """One header node and one node per data row, written as "column=value" pairs. + + A row keeps its column names, so a short row still says what its values are. The row's place is + its row number in the file (RC1:RC, /rows/) and the line where it starts. + A file whose first row holds a number has no header row: the columns are named col1, col2, ... + """ + name = "csv" - version = "1.0.0" + version = "1.1.0" media_types = frozenset({"text/csv"}) suffixes = frozenset({".csv"}) def parse(self, artifact: ArtifactEnvelope, raw: bytes) -> StructuralDocument: validate_byte_hash(artifact, raw) - rows = csv.reader(io.StringIO(raw.decode("utf-8-sig"))) + reader = csv.reader(io.StringIO(raw.decode("utf-8-sig"))) nodes: list[StructuralNode] = [] - for row_number, row in enumerate(rows, start=1): - for column_number, value in enumerate(row, start=1): - if not value.strip(): - continue - cell = f"R{row_number}C{column_number}" - nodes.append( - StructuralNode( - node_id=node_id(artifact, len(nodes), "CELL"), - artifact_id=artifact.artifact_id, - kind=StructuralNodeKind.CELL, - ordinal=len(nodes), - text=value, - locator=SourceLocator(cell_range=cell), - ) + names: list[str] | None = None + first = True + end_line = 0 + for row_number, row in enumerate(reader, start=1): + start_line, end_line = end_line + 1, reader.line_num + if not any(value.strip() for value in row): + continue + header = first and _is_header(row) + if header: + names = [value.strip() for value in row] + first = False + kind = StructuralNodeKind.HEADER if header else StructuralNodeKind.ROW + text = ( + ", ".join(value for value in names or () if value) + if header + else ", ".join( + f"{_column(names, index)}={value.strip()}" + for index, value in enumerate(row) + if value.strip() + ) + ) + nodes.append( + StructuralNode( + node_id=node_id(artifact, len(nodes), kind.value), + artifact_id=artifact.artifact_id, + kind=kind, + ordinal=len(nodes), + text=text, + locator=SourceLocator( + line=start_line, + cell_range=_row_range(row_number, len(row)), + json_pointer="/header" if header else f"/rows/{row_number}", + ), ) + ) return StructuralDocument( artifact=parser_artifact(artifact, name=self.name, version=self.version), nodes=tuple(nodes), diff --git a/src/thoth/adapters/parsers/text_parser.py b/src/thoth/adapters/parsers/text_parser.py index 5ea3e85..11097ac 100644 --- a/src/thoth/adapters/parsers/text_parser.py +++ b/src/thoth/adapters/parsers/text_parser.py @@ -1,5 +1,8 @@ from __future__ import annotations +import re +from pathlib import PurePosixPath + from thoth.adapters.parsers.common import coverage, node_id, parser_artifact, validate_byte_hash from thoth.domain.artifact import ( ArtifactEnvelope, @@ -9,10 +12,45 @@ ) from thoth.domain.enums import StructuralNodeKind +_YAML_SUFFIXES = frozenset({".yaml", ".yml"}) +_KEY_LINE = re.compile(r"^(?P *)(?P[A-Za-z0-9_][\w.\-]*)\s*:(?:\s|$)") +_LIST_ITEM = re.compile(r"^(?P *)-(?:\s|$)") + + +def _is_yaml(artifact: ArtifactEnvelope) -> bool: + name = PurePosixPath(artifact.source_uri.replace("\\", "/")) + return name.suffix.lower() in _YAML_SUFFIXES + + +class _KeyPath: + """Track the indentation-based key path of a YAML file without interpreting its values.""" + + def __init__(self) -> None: + self._stack: list[tuple[int, str]] = [] + self._items: dict[str, int] = {} + + def pointer(self, line: str) -> str | None: + key_line = _KEY_LINE.match(line) + item = None if key_line else _LIST_ITEM.match(line) + match = key_line or item + if match is None: + return None + indent = len(match.group("indent")) + while self._stack and self._stack[-1][0] >= indent: + self._stack.pop() + parent = "".join(f"/{key}" for _, key in self._stack) + if key_line is not None: + key = key_line.group("key") + self._stack.append((indent, key)) + return f"{parent}/{key}" + index = self._items.get(parent, 0) + self._items[parent] = index + 1 + return f"{parent}/{index}" + class TextParser: name = "text" - version = "1.0.0" + version = "1.1.0" media_types = frozenset({"text/plain", "text/markdown"}) # .yaml/.yml are read as plain lines of text. Nothing in them is interpreted or run. suffixes = frozenset({".txt", ".md", ".markdown", ".yaml", ".yml"}) @@ -20,6 +58,9 @@ class TextParser: def parse(self, artifact: ArtifactEnvelope, raw: bytes) -> StructuralDocument: validate_byte_hash(artifact, raw) text = raw.decode("utf-8-sig") + # A YAML line keeps its key path (for example /detection/denominator) so a block of short + # key-value lines can be read together. Other text files are left as flat lines. + key_path = _KeyPath() if _is_yaml(artifact) else None nodes: list[StructuralNode] = [] paragraph = 0 for line_number, line in enumerate(text.splitlines(), start=1): @@ -27,6 +68,7 @@ def parse(self, artifact: ArtifactEnvelope, raw: bytes) -> StructuralDocument: if not value: continue paragraph += 1 + pointer = None if key_path is None or value.startswith("#") else key_path.pointer(line) kind = ( StructuralNodeKind.SECTION if value.startswith("#") @@ -39,7 +81,9 @@ def parse(self, artifact: ArtifactEnvelope, raw: bytes) -> StructuralDocument: kind=kind, ordinal=len(nodes), text=value, - locator=SourceLocator(line=line_number, paragraph=paragraph), + locator=SourceLocator( + line=line_number, paragraph=paragraph, json_pointer=pointer + ), ) ) return StructuralDocument( diff --git a/src/thoth/application/services/acquisition_coordinator.py b/src/thoth/application/services/acquisition_coordinator.py index f363424..92a12c3 100644 --- a/src/thoth/application/services/acquisition_coordinator.py +++ b/src/thoth/application/services/acquisition_coordinator.py @@ -5,10 +5,10 @@ from pydantic import JsonValue -from thoth.application.services.connector_service import ConnectorService +from thoth.application.services.connector_service import ConnectorAcquisition, ConnectorService from thoth.application.services.evidence_graph_service import EvidenceGraphService from thoth.application.services.investigation_service import InvestigationService -from thoth.domain.acquisition import AcquisitionLead, SearchIntent +from thoth.domain.acquisition import AcquisitionLead, EvidenceAtomicCommit, SearchIntent from thoth.domain.canonical import canonical_payload, domain_digest from thoth.domain.connectors import ConnectorAccessRequest, ConnectorFailure from thoth.domain.enums import ( @@ -18,6 +18,8 @@ SufficiencyStatus, ) from thoth.domain.evidence import EvidenceSpan, InformationSufficiencyAssessment +from thoth.domain.governance import ProjectPolicy +from thoth.domain.investigation import InvestigationRecord from thoth.domain.policy import AcquisitionRoute, AuthoritativeExecutionPolicy from thoth.domain.project import Project, WorkThread from thoth.ports.acquisition import AcquisitionTraceStorePort, EvidenceUnitOfWorkPort @@ -66,13 +68,10 @@ async def execute_if_required( stored_policy = self._policies.read_policy(project.project_id) if stored_policy is None: return None - policy = AuthoritativeExecutionPolicy.from_project_policy(stored_policy) - target_gaps = self._target_gaps(policy.acquisition_routes, evidence) - if not target_gaps: + prepared = self._prepare_route(stored_policy, evidence) + if prepared is None: return None - route = next( - item for item in policy.acquisition_routes if item.evidence_group == target_gaps[0] - ) + policy, target_gaps, route = prepared investigation = self._investigations.start( thread=thread, trigger="SUFFICIENCY_GAP", @@ -127,15 +126,11 @@ async def execute_if_required( stopped = self._investigations.stop( investigation, reason=( - "PERMISSION_BLOCKED" - if exc.policy_denial is not None - else "CONNECTOR_FAILED" + "PERMISSION_BLOCKED" if exc.policy_denial is not None else "CONNECTOR_FAILED" ), ) projection: dict[str, JsonValue] = { - "terminal_state": ( - "POLICY_BLOCKED" if exc.policy_denial is not None else "FAILED" - ), + "terminal_state": ("POLICY_BLOCKED" if exc.policy_denial is not None else "FAILED"), "reanalysis_performed": False, "minimum_question": ( f"Project policy must authorize evidence group {route.evidence_group}." @@ -202,9 +197,7 @@ async def execute_if_required( "remaining_target_gaps": list(target_gaps), "search_intent": cast(JsonValue, intent.model_dump(mode="json")), "investigation": cast(JsonValue, stopped.model_dump(mode="json")), - "connector_run": cast( - JsonValue, acquisition.run.model_dump(mode="json") - ), + "connector_run": cast(JsonValue, acquisition.run.model_dump(mode="json")), }, acquired_evidence=all_acquired_spans, ) @@ -223,12 +216,51 @@ async def execute_if_required( waved, reason="SUFFICIENT" if route.evidence_group not in remaining else "SEARCH_SATURATED", ) + return self._acquisition_result( + route=route, + remaining=remaining, + intent=intent, + stopped=stopped, + acquisition=acquisition, + staged=staged, + lead=lead, + spans=spans, + all_acquired_spans=all_acquired_spans, + route_capability=route_capability, + ) + + def _prepare_route( + self, + stored_policy: ProjectPolicy, + evidence: tuple[EvidenceSpan, ...], + ) -> tuple[AuthoritativeExecutionPolicy, tuple[str, ...], AcquisitionRoute] | None: + policy = AuthoritativeExecutionPolicy.from_project_policy(stored_policy) + target_gaps = self._target_gaps(policy.acquisition_routes, evidence) + if not target_gaps: + return None + route = next( + item for item in policy.acquisition_routes if item.evidence_group == target_gaps[0] + ) + return policy, target_gaps, route + + @staticmethod + def _acquisition_result( + *, + route: AcquisitionRoute, + remaining: tuple[str, ...], + intent: SearchIntent, + stopped: InvestigationRecord, + acquisition: ConnectorAcquisition, + staged: EvidenceAtomicCommit, + lead: AcquisitionLead, + spans: tuple[EvidenceSpan, ...], + all_acquired_spans: tuple[EvidenceSpan, ...], + route_capability: dict[str, JsonValue] | None, + ) -> AutonomousAcquisitionExecution: return AutonomousAcquisitionExecution( projection={ "terminal_state": ( - "SUFFICIENT" - if route.evidence_group not in remaining - else "SEARCH_SATURATED" + "SUFFICIENT" if route.evidence_group not in remaining else "SEARCH_SATURATED" ), "reanalysis_performed": route.evidence_group not in remaining, "minimum_question": ( @@ -240,14 +272,10 @@ async def execute_if_required( "search_intent": cast(JsonValue, intent.model_dump(mode="json")), "investigation": cast(JsonValue, stopped.model_dump(mode="json")), "connector_run": cast(JsonValue, acquisition.run.model_dump(mode="json")), - "connector_receipt": cast( - JsonValue, acquisition.receipt.model_dump(mode="json") - ), + "connector_receipt": cast(JsonValue, acquisition.receipt.model_dump(mode="json")), "observation": cast(JsonValue, staged.observation.model_dump(mode="json")), "lead": cast(JsonValue, lead.model_dump(mode="json")), - "claim_candidate": cast( - JsonValue, staged.claim_candidate.model_dump(mode="json") - ), + "claim_candidate": cast(JsonValue, staged.claim_candidate.model_dump(mode="json")), "acquired_evidence_refs": [item.span_id for item in spans], "route_capability_snapshot": route_capability, "route_usage": { @@ -297,9 +325,7 @@ def _search_intent( return SearchIntent.model_validate( { **draft, - "intent_digest": domain_digest( - "SEARCH_INTENT", "1.0.0", canonical_payload(draft) - ), + "intent_digest": domain_digest("SEARCH_INTENT", "1.0.0", canonical_payload(draft)), } ) @@ -328,9 +354,7 @@ def _lead( return AcquisitionLead.model_validate( { **draft, - "lead_digest": domain_digest( - "ACQUISITION_LEAD", "1.0.0", canonical_payload(draft) - ), + "lead_digest": domain_digest("ACQUISITION_LEAD", "1.0.0", canonical_payload(draft)), } ) diff --git a/src/thoth/application/services/counter_search_calculations.py b/src/thoth/application/services/counter_search_calculations.py new file mode 100644 index 0000000..dae04b0 --- /dev/null +++ b/src/thoth/application/services/counter_search_calculations.py @@ -0,0 +1,104 @@ +"""Pure terminal, gate input and reported-budget calculations after the stateful wave loop.""" + +from thoth.domain.counterevidence import ( + CounterLoopTerminal, + CounterReviewTerminal, + CounterSearchBudgetState, + CounterWaveRecord, +) +from thoth.domain.policy import CounterSearchLimits + + +def resolve_loop_terminal( + *, + loop_terminal: CounterLoopTerminal | None, + budget_exhausted: bool, + counter_refs: list[str], + support_refs: list[str], + policy_blocks: int, + authority_blocks: int, + abstention_blocks: int, + waves: list[CounterWaveRecord], +) -> CounterLoopTerminal: + if loop_terminal is None: + if budget_exhausted: + loop_terminal = CounterLoopTerminal.BUDGET_EXHAUSTED + elif counter_refs and support_refs: + loop_terminal = CounterLoopTerminal.ABSTAINED + elif policy_blocks and not waves: + loop_terminal = CounterLoopTerminal.POLICY_BLOCKED + elif authority_blocks and not waves: + loop_terminal = CounterLoopTerminal.AUTHORITY_REQUIRED + elif abstention_blocks: + loop_terminal = CounterLoopTerminal.ABSTAINED + else: + loop_terminal = CounterLoopTerminal.SEARCH_SATURATED + if ( + counter_refs + and support_refs + and loop_terminal + not in { + CounterLoopTerminal.ELIMINATED_WITHIN_SCOPE, + CounterLoopTerminal.SUPPORTED, + } + ): + loop_terminal = CounterLoopTerminal.ABSTAINED + return loop_terminal + + +def aggregate_gate_inputs( + loop_terminal: CounterLoopTerminal, + counter_refs: list[str], + support_refs: list[str], +) -> tuple[CounterReviewTerminal, tuple[str, ...]]: + gate_terminal = { + CounterLoopTerminal.ELIMINATED_WITHIN_SCOPE: ( + CounterReviewTerminal.ELIMINATED_WITHIN_SCOPE + ), + CounterLoopTerminal.SUPPORTED: CounterReviewTerminal.SUPPORTED, + CounterLoopTerminal.SEARCH_SATURATED: CounterReviewTerminal.UNRESOLVED_NO_RESULTS, + CounterLoopTerminal.BUDGET_EXHAUSTED: CounterReviewTerminal.UNRESOLVED_FAILED, + CounterLoopTerminal.POLICY_BLOCKED: CounterReviewTerminal.UNRESOLVED_POLICY_BLOCKED, + CounterLoopTerminal.AUTHORITY_REQUIRED: CounterReviewTerminal.UNRESOLVED_AUTHORITY, + CounterLoopTerminal.ABSTAINED: CounterReviewTerminal.UNRESOLVED_CONFLICT, + }[loop_terminal] + accepted_refs = ( + tuple(dict.fromkeys(counter_refs)) + if loop_terminal == CounterLoopTerminal.ELIMINATED_WITHIN_SCOPE + else tuple(dict.fromkeys(support_refs)) + if loop_terminal == CounterLoopTerminal.SUPPORTED + else () + ) + return gate_terminal, accepted_refs + + +def budget_snapshot( + *, + limits: CounterSearchLimits, + used_waves: int, + used_results: int, + used_documents: int, + used_bytes: int, + used_tool_calls: int, + used_cost: int, + elapsed: int, +) -> CounterSearchBudgetState: + return CounterSearchBudgetState( + max_waves=limits.max_waves, + max_results=limits.max_results, + max_documents=limits.max_documents, + max_bytes=limits.max_bytes, + max_model_calls=limits.max_model_calls, + max_tool_calls=limits.max_tool_calls, + max_time_seconds=limits.max_time_seconds, + max_cost_microunits=limits.max_cost_microunits, + max_depth=limits.max_depth, + used_waves=used_waves, + used_results=used_results, + used_documents=used_documents, + used_bytes=used_bytes, + used_model_calls=0, + used_tool_calls=used_tool_calls, + used_time_seconds=min(elapsed, limits.max_time_seconds), + used_cost_microunits=used_cost, + ) diff --git a/src/thoth/application/services/critical_counter_search_coordinator.py b/src/thoth/application/services/critical_counter_search_coordinator.py index 34291de..7fb8ebe 100644 --- a/src/thoth/application/services/critical_counter_search_coordinator.py +++ b/src/thoth/application/services/critical_counter_search_coordinator.py @@ -7,6 +7,11 @@ from pydantic import JsonValue from thoth.application.services.connector_service import ConnectorService +from thoth.application.services.counter_search_calculations import ( + aggregate_gate_inputs, + budget_snapshot, + resolve_loop_terminal, +) from thoth.application.services.critical_counter_search import ( CounterevidenceChallenger, CounterSearchBasis, @@ -27,7 +32,6 @@ from thoth.domain.counterevidence import ( CounterLoopTerminal, CounterReviewTerminal, - CounterSearchBudgetState, CounterSearchPlan, CounterWaveRecord, ) @@ -616,48 +620,20 @@ async def _execute_multi_wave( if low_voi_streak >= 2: loop_terminal = CounterLoopTerminal.SEARCH_SATURATED break - if loop_terminal is None: - if budget_exhausted: - loop_terminal = CounterLoopTerminal.BUDGET_EXHAUSTED - elif counter_refs and support_refs: - loop_terminal = CounterLoopTerminal.ABSTAINED - elif policy_blocks and not waves: - loop_terminal = CounterLoopTerminal.POLICY_BLOCKED - elif authority_blocks and not waves: - loop_terminal = CounterLoopTerminal.AUTHORITY_REQUIRED - elif abstention_blocks: - loop_terminal = CounterLoopTerminal.ABSTAINED - else: - loop_terminal = CounterLoopTerminal.SEARCH_SATURATED - if ( - counter_refs - and support_refs - and loop_terminal - not in { - CounterLoopTerminal.ELIMINATED_WITHIN_SCOPE, - CounterLoopTerminal.SUPPORTED, - } - ): - loop_terminal = CounterLoopTerminal.ABSTAINED + loop_terminal = resolve_loop_terminal( + loop_terminal=loop_terminal, + budget_exhausted=budget_exhausted, + counter_refs=counter_refs, + support_refs=support_refs, + policy_blocks=policy_blocks, + authority_blocks=authority_blocks, + abstention_blocks=abstention_blocks, + waves=waves, + ) if not plans: raise ValueError("multi-wave counter-search produced no challenger plan") - gate_terminal = { - CounterLoopTerminal.ELIMINATED_WITHIN_SCOPE: ( - CounterReviewTerminal.ELIMINATED_WITHIN_SCOPE - ), - CounterLoopTerminal.SUPPORTED: CounterReviewTerminal.SUPPORTED, - CounterLoopTerminal.SEARCH_SATURATED: CounterReviewTerminal.UNRESOLVED_NO_RESULTS, - CounterLoopTerminal.BUDGET_EXHAUSTED: CounterReviewTerminal.UNRESOLVED_FAILED, - CounterLoopTerminal.POLICY_BLOCKED: CounterReviewTerminal.UNRESOLVED_POLICY_BLOCKED, - CounterLoopTerminal.AUTHORITY_REQUIRED: CounterReviewTerminal.UNRESOLVED_AUTHORITY, - CounterLoopTerminal.ABSTAINED: CounterReviewTerminal.UNRESOLVED_CONFLICT, - }[loop_terminal] - accepted_refs = ( - tuple(dict.fromkeys(counter_refs)) - if loop_terminal == CounterLoopTerminal.ELIMINATED_WITHIN_SCOPE - else tuple(dict.fromkeys(support_refs)) - if loop_terminal == CounterLoopTerminal.SUPPORTED - else () + gate_terminal, accepted_refs = aggregate_gate_inputs( + loop_terminal, counter_refs, support_refs ) gate = self._reviewer.aggregate( plans[0], @@ -675,24 +651,15 @@ async def _execute_multi_wave( reason=self._loop_stop_reason(loop_terminal), ) elapsed = max(0, int((self._clock.now() - started_at).total_seconds())) - budget = CounterSearchBudgetState( - max_waves=limits.max_waves, - max_results=limits.max_results, - max_documents=limits.max_documents, - max_bytes=limits.max_bytes, - max_model_calls=limits.max_model_calls, - max_tool_calls=limits.max_tool_calls, - max_time_seconds=limits.max_time_seconds, - max_cost_microunits=limits.max_cost_microunits, - max_depth=limits.max_depth, + budget = budget_snapshot( + limits=limits, used_waves=used_waves, used_results=used_results, used_documents=used_documents, used_bytes=used_bytes, - used_model_calls=0, used_tool_calls=used_tool_calls, - used_time_seconds=min(elapsed, limits.max_time_seconds), - used_cost_microunits=used_cost, + used_cost=used_cost, + elapsed=elapsed, ) return CriticalCounterSearchExecution( projection=self._finalizer.multi( diff --git a/src/thoth/application/services/hypothesis_contract_v3.py b/src/thoth/application/services/hypothesis_contract_v3.py index 86167d1..8fadb0f 100644 --- a/src/thoth/application/services/hypothesis_contract_v3.py +++ b/src/thoth/application/services/hypothesis_contract_v3.py @@ -8,9 +8,20 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import TypedDict +from thoth.application.services.text_script import ( + HAN, + HANGUL, + KATAKANA_MIDDLE_DOT, + Quotes, + korean_middle_dot, + language_problem, + visible_problem, +) +from thoth.domain.enums import HypothesisStatus from thoth.domain.evidence import EvidenceSpan from thoth.domain.hypothesis import ( DiscriminatingTest, @@ -24,7 +35,16 @@ from thoth.domain.research_projection import DroppedExpectation, ExpectedByTest CONTRACT_V2 = "hypothesis_portfolio.v2" -CONTRACT_V3 = "hypothesis_portfolio.v3" +CONTRACT_V3 = "hypothesis_portfolio.v3.3" +REFUTATION_LANGUAGE_MISMATCH = "REFUTATION_LANGUAGE_MISMATCH" +REFUTATION_SCRIPT_MISMATCH = "REFUTATION_SCRIPT_MISMATCH" +# The readable lists of a hypothesis, and what is made of one that is in the wrong script. +LIST_TEXT_FIELDS = ( + "missing_evidence", + "counterevidence_queries", + "predicted_observations", + "assumptions", +) class V3DetailFields(TypedDict, total=False): @@ -78,9 +98,155 @@ class _Settled: dropped: tuple[DroppedExpectation, ...] +def _refutation_language( + hypothesis: HypothesisV3, problem: str +) -> tuple[tuple[str, ...], list[DroppedExpectation]]: + """The refutation conditions that stay in the question's language, and what was dropped. + + A question with Hangul gets Hangul sentences: a condition with no Hangul is dropped, and so + is one with Chinese characters unless the question itself has some. A question without + Hangul leaves every condition as it was. Each drop keeps the original text. + """ + conditions = hypothesis.refutation_conditions + if not HANGUL.search(problem): + return conditions, [] + dropped: list[DroppedExpectation] = [] + + def keep(reason: str, bad: Callable[[str], bool], items: tuple[str, ...]) -> tuple[str, ...]: + dropped.extend( + DroppedExpectation( + test_id="", hypothesis_id=hypothesis.hypothesis_id, reason=reason, text=item + ) + for item in items + if bad(item) + ) + return tuple(item for item in items if not bad(item)) + + kept = keep(REFUTATION_LANGUAGE_MISMATCH, lambda item: not HANGUL.search(item), conditions) + if not HAN.search(problem): + kept = keep(REFUTATION_SCRIPT_MISMATCH, lambda item: bool(HAN.search(item)), kept) + return kept, dropped + + +def _flag(hypothesis_id: str, test_id: str, field: str, text: str, kind: str) -> DroppedExpectation: + return DroppedExpectation( + test_id=test_id, + hypothesis_id=hypothesis_id, + reason=f"{field.upper()}_{kind}_FLAGGED", + text=text, + ) + + +def _visible_text( + hypothesis: HypothesisV3, problem: str, quotes: Quotes +) -> tuple[dict[str, tuple[str, ...]], list[DroppedExpectation]]: + """What a Hangul question leaves of the readable text of a hypothesis. + + A list item (missing evidence, counterevidence queries, predicted observations, assumptions) in + the wrong language or script is dropped and recorded with its text, unless dropping the last one + would leave a record the model forbids (no support refs and no gap; no counterevidence ref and + no query; a non-draft hypothesis with no prediction): then it stays and is flagged. A single + text (statement, uncertainty, a test's procedure and expected texts) is never removed; a + problem is only recorded as FLAGGED. + """ + updates: dict[str, tuple[str, ...]] = {} + records: list[DroppedExpectation] = [] + if not HANGUL.search(problem): + return updates, records + hypothesis_id = hypothesis.hypothesis_id + guarded = { + "missing_evidence": not hypothesis.support_evidence_refs, + "counterevidence_queries": not hypothesis.counterevidence_refs, + "predicted_observations": hypothesis.status != HypothesisStatus.DRAFT, + } + for field in LIST_TEXT_FIELDS: + items: tuple[str, ...] = getattr(hypothesis, field) + found = [(item, visible_problem(item, problem, quotes)) for item in items] + bad = [(item, kind) for item, kind in found if kind is not None] + if not bad: + continue + keep = tuple(item for item, kind in found if kind is None) + if not keep and guarded.get(field, False): + records.extend(_flag(hypothesis_id, "", field, item, kind) for item, kind in bad) + continue + updates[field] = keep + records.extend( + DroppedExpectation( + test_id="", + hypothesis_id=hypothesis_id, + reason=f"{field.upper()}_{kind}_MISMATCH", + text=item, + ) + for item, kind in bad + ) + for field in ("statement", "uncertainty"): + text = getattr(hypothesis, field) + if (kind := visible_problem(text, problem, quotes)) is not None: + records.append(_flag(hypothesis_id, "", field, text, kind)) + for test in hypothesis.discriminating_tests: + for field in ("procedure_candidate", "expected_if_true", "expected_if_alternative"): + text = getattr(test, field) + if (kind := visible_problem(text, problem, quotes)) is not None: + records.append(_flag(hypothesis_id, test.test_id, field, text, kind)) + return updates, records + + +def _map_texts(hypothesis: HypothesisV3, change: Callable[[str], str]) -> HypothesisV3: + """The hypothesis with every readable text passed through change (ids and numbers untouched).""" + tests = tuple( + test.model_copy( + update={ + "procedure_candidate": change(test.procedure_candidate), + "expected_if_true": change(test.expected_if_true), + "expected_if_alternative": change(test.expected_if_alternative), + "expected_by_hypothesis": tuple( + row.model_copy(update={"expected": change(row.expected)}) + for row in test.expected_by_hypothesis + ), + } + ) + for test in hypothesis.discriminating_tests + ) + lists = { + field: tuple(change(item) for item in getattr(hypothesis, field)) + for field in (*LIST_TEXT_FIELDS, "refutation_conditions") + } + return hypothesis.model_copy( + update={ + "statement": change(hypothesis.statement), + "uncertainty": change(hypothesis.uncertainty), + "discriminating_tests": tests, + **lists, + } + ) + + +def _with_korean_dots(hypothesis: HypothesisV3, problem: str, quotes: Quotes) -> HypothesisV3: + """A Hangul question gets the Korean middle dot where the model wrote the katakana one, in every + readable text and without a record. A dot the question or the evidence has is a quotation and + stays.""" + if not HANGUL.search(problem): + return hypothesis + seen: list[None] = [] + + def change(text: str) -> str: + if KATAKANA_MIDDLE_DOT in text: + seen.append(None) + return korean_middle_dot(text) + + shown = _map_texts(hypothesis, change) + return hypothesis if not seen or quotes.has(KATAKANA_MIDDLE_DOT) else shown + + def _settle_one( - hypothesis: HypothesisV3, known_hypotheses: set[str], known_spans: set[str] + hypothesis: HypothesisV3, + known_hypotheses: set[str], + known_spans: set[str], + problem: str = "", + quotes: Quotes | None = None, ) -> _Settled: + quotes = quotes or Quotes(problem, list) + hypothesis = _with_korean_dots(hypothesis, problem, quotes) dropped: list[DroppedExpectation] = [] tests: list[DiscriminatingTestV3] = [] for test in hypothesis.discriminating_tests: @@ -95,30 +261,54 @@ def _settle_one( if not set(row.basis) <= known_spans else None ) + wrong_language = language_problem(row.expected, problem) + if reason is None and wrong_language is not None: + reason = f"EXPECTED_{wrong_language}_MISMATCH" if reason is None: kept.append(row) else: dropped.append( DroppedExpectation( - test_id=test.test_id, hypothesis_id=row.hypothesis_id, reason=reason + test_id=test.test_id, + hypothesis_id=row.hypothesis_id, + reason=reason, + text=row.expected if reason.startswith("EXPECTED_") else None, ) ) tests.append(test.model_copy(update={"expected_by_hypothesis": tuple(kept)})) - settled = hypothesis.model_copy(update={"discriminating_tests": tuple(tests)}) + conditions, language_drops = _refutation_language(hypothesis, problem) + dropped.extend(language_drops) + visible, visible_records = _visible_text(hypothesis, problem, quotes) + dropped.extend(visible_records) + settled = hypothesis.model_copy( + update={ + "discriminating_tests": tuple(tests), + "refutation_conditions": conditions, + **visible, + } + ) return _Settled(settled, tuple(dropped)) def settle_contract( - portfolio: HypothesisPortfolio, evidence: tuple[EvidenceSpan, ...] + portfolio: HypothesisPortfolio, evidence: tuple[EvidenceSpan, ...], problem: str = "" ) -> HypothesisPortfolio: - """Keep the rows of a v3 output that name known hypotheses and spans; v2 is untouched.""" + """Keep what a v3 output says correctly; v2 is untouched. + + Expected-result rows must name known hypotheses and spans; when the question is Korean, a + label, refutation condition or readable list item (missing evidence, counterevidence queries, + predicted observations, assumptions) with no Hangul, or with Chinese characters or kana the + question and the evidence lack, is dropped too; a single readable text is kept and only + flagged. Every drop is recorded with its text. + """ if not isinstance(portfolio, HypothesisPortfolioV3): return portfolio known_hypotheses = {item.hypothesis_id for item in portfolio.hypotheses} known_spans = {span.span_id for span in evidence} + quotes = Quotes(problem, lambda: (span.exact_text for span in evidence)) settled: list[HypothesisV3] = [] for hypothesis in portfolio.hypotheses: - found = _settle_one(hypothesis, known_hypotheses, known_spans) + found = _settle_one(hypothesis, known_hypotheses, known_spans, problem, quotes) found.hypothesis.note_dropped(found.dropped) settled.append(found.hypothesis) return portfolio.model_copy(update={"hypotheses": tuple(settled)}) diff --git a/src/thoth/application/services/hypothesis_projection.py b/src/thoth/application/services/hypothesis_projection.py index 2dd0bf6..285c8e6 100644 --- a/src/thoth/application/services/hypothesis_projection.py +++ b/src/thoth/application/services/hypothesis_projection.py @@ -10,6 +10,7 @@ from thoth.domain.hypothesis import ( Hypothesis, HypothesisPortfolio, + HypothesisPortfolioV3, HypothesisV3, ) from thoth.domain.hypothesis_full import HypothesisPortfolioRecord, HypothesisRecord @@ -284,6 +285,9 @@ def full_portfolio( alternatives_considered=candidate.alternatives_considered, next_checks=candidate.next_checks, uncertainty_reserve=candidate.uncertainty_reserve, + table_fill=candidate.table_fill + if isinstance(candidate, HypothesisPortfolioV3) + else None, ), "supersedes_revision_digest": parent, "created_at": created_at, diff --git a/src/thoth/application/services/hypothesis_table_filler.py b/src/thoth/application/services/hypothesis_table_filler.py new file mode 100644 index 0000000..f5b4ec4 --- /dev/null +++ b/src/thoth/application/services/hypothesis_table_filler.py @@ -0,0 +1,368 @@ +"""A second, narrow call that fills the expected-result cells the generator left open (v3 only). + +The generator writes the hypotheses, the tests, the tables and the refutation conditions in one +call and tends to pass quickly over the cells of hypotheses a test was not designed for. After the +output is settled this call is asked for those cells only. The merge is decided here, not by the +model: a designed-for cell is the generator's own and stays; a cell the generator filled is not +overwritten; only a cell that is missing or says 모름 may change. If the call cannot be made or its +answer cannot be used, the table stays as the generator wrote it and the reason is recorded; the +research never fails because of this call. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Protocol + +from pydantic import ValidationError + +from thoth.application.reducers import validate_hypothesis_portfolio +from thoth.application.services.hypothesis_contract_v3 import language_problem, settle_contract +from thoth.domain.enums import ModelRole +from thoth.domain.evidence import EvidenceSpan +from thoth.domain.hypothesis import ( + DiscriminatingTestV3, + ExpectedResult, + FilledRow, + HypothesisPortfolio, + HypothesisPortfolioV3, + HypothesisTableFill, + HypothesisV3, + TableFillAnswer, + TableFillOutcome, + TableFillRecord, +) +from thoth.domain.model import ContextPack, ModelRequest +from thoth.domain.research_execution import research_work +from thoth.domain.research_projection import DroppedExpectation +from thoth.ports.model import ModelExecutionHold, ModelPort + +TABLE_FILLER_VERSION = "hypothesis_table_filler.v4" +# Only the open cells are asked for (about fifty tokens a row), so this is below the generator's +# 4,000 output tokens; it is not raised to make room. +TABLE_FILLER_MAX_OUTPUT_TOKENS = 3_000 +UNKNOWN = "모름" +MAX_LABEL_CHARS = 80 +MAX_BASIS = 5 +MAX_ROWS = 10 +# A hold that says the research's own limits are used up: the call is skipped, not failed. +BUDGET_HOLDS = frozenset({"SERIALIZED_MODEL_INPUT_LIMIT", "RESEARCH_BUDGET_MISSING"}) +UNUSABLE_ANSWER = "TABLE_FILL_OUTPUT_INVALID" + + +class CycleInput(Protocol): + @property + def cutoff_at(self) -> datetime: ... + @property + def model_policy_ref(self) -> str: ... + @property + def evidence(self) -> tuple[EvidenceSpan, ...]: ... + + +def _label(value: str) -> str: + return " ".join(value.split()) + + +def _rows_of(test: DiscriminatingTestV3) -> dict[str, str]: + return {row.hypothesis_id: _label(row.expected) for row in test.expected_by_hypothesis} + + +def _emptied_by_language(owner: HypothesisV3, test: DiscriminatingTestV3) -> bool: + """True when settling dropped the designed-for cell's own label for its language or script.""" + return any( + item.test_id == test.test_id + and item.hypothesis_id == owner.hypothesis_id + and item.reason.startswith("EXPECTED_") + and item.reason.endswith("_MISMATCH") + for item in owner.dropped + ) + + +def _open_ids( + portfolio: HypothesisPortfolioV3, owner: HypothesisV3, test: DiscriminatingTestV3 +) -> tuple[str, ...]: + """The hypotheses whose cell is missing or says 모름, other than the one the test is for. + + The designed-for cell is the generator's own and stays; it is open only when settling dropped + its label for its language or script: the cell is empty only because of the label's wording. + """ + rows = _rows_of(test) + own_open = owner.hypothesis_id not in rows and _emptied_by_language(owner, test) + return tuple( + item.hypothesis_id + for item in portfolio.hypotheses + if (item.hypothesis_id != owner.hypothesis_id or own_open) + and rows.get(item.hypothesis_id, UNKNOWN) == UNKNOWN + ) + + +def count_cells(portfolio: HypothesisPortfolioV3) -> tuple[int, int]: + """(cells, open cells) over every test and every hypothesis, the designed-for cell included.""" + cells = opened = 0 + for owner in portfolio.hypotheses: + for test in owner.discriminating_tests: + rows = _rows_of(test) + for item in portfolio.hypotheses: + cells += 1 + opened += rows.get(item.hypothesis_id, UNKNOWN) == UNKNOWN + return cells, opened + + +def _payload(portfolio: HypothesisPortfolioV3) -> dict[str, object]: + return { + "hypotheses": [ + { + "hypothesis_id": item.hypothesis_id, + "statement": item.statement, + "predicted_observations": list(item.predicted_observations), + } + for item in portfolio.hypotheses + ], + "tests": [ + { + "designed_for": owner.hypothesis_id, + "test_id": test.test_id, + "procedure": test.procedure_candidate, + "expected_if_true": test.expected_if_true, + "expected_if_alternative": test.expected_if_alternative, + "table": [ + {"hypothesis_id": row.hypothesis_id, "expected": row.expected} + for row in test.expected_by_hypothesis + ], + "open_hypothesis_ids": list(open_ids), + } + for owner in portfolio.hypotheses + for test in owner.discriminating_tests + if (open_ids := _open_ids(portfolio, owner, test)) + ], + } + + +def _request_context(context: ContextPack, portfolio: HypothesisPortfolioV3) -> ContextPack: + """The same question and evidence as the generator saw, and only the tables to fill.""" + return ContextPack( + case_id=context.case_id, + project_id=context.project_id, + object_id=context.object_id, + problem=context.problem, + evidence=context.evidence, + criteria=(), + sufficiency=None, + input_head_set_digest=context.input_head_set_digest, + research_context={ + "task": "Fill the open cells of the expected-result tables; change nothing else.", + "table_filler": _payload(portfolio), + }, + ) + + +def _row_problem(row: FilledRow, open_ids: set[str], known: set[str], problem: str) -> str | None: + """Why this answer for one cell is not kept, or None when it is.""" + label = _label(row.expected) + if row.hypothesis_id not in known: + return "UNKNOWN_HYPOTHESIS" + if row.hypothesis_id not in open_ids: + return "TABLE_FILL_CELL_NOT_OPEN" + if label == UNKNOWN: + return None + if len(label) > MAX_LABEL_CHARS: + return "EXPECTED_TOO_LONG" + mismatch = language_problem(label, problem) + return None if mismatch is None else f"EXPECTED_{mismatch}_MISMATCH" + + +def merge_fill( + portfolio: HypothesisPortfolioV3, + fill: HypothesisTableFill, + known_spans: set[str], + problem: str, +) -> tuple[HypothesisPortfolioV3, int, tuple[TableFillAnswer, ...]]: + """The portfolio with the answered cells merged in, how many cells changed to a value, and + what the filler said for each cell it was asked about. + + Every answer that is not kept is recorded on the hypothesis the test was designed for. + """ + known = {item.hypothesis_id for item in portfolio.hypotheses} + position = {item.hypothesis_id: index for index, item in enumerate(portfolio.hypotheses)} + tests = { + (owner.hypothesis_id, test.test_id): (owner, test, set(_open_ids(portfolio, owner, test))) + for owner in portfolio.hypotheses + for test in owner.discriminating_tests + } + kept: dict[tuple[str, str], dict[str, ExpectedResult]] = {} + said: dict[tuple[str, str, str], TableFillAnswer] = {} + dropped: dict[str, list[DroppedExpectation]] = {} + + def drop(owner_id: str, test_id: str, hypothesis_id: str, reason: str, text: str = "") -> None: + owner = owner_id if owner_id in known else portfolio.hypotheses[0].hypothesis_id + dropped.setdefault(owner, []).append( + DroppedExpectation( + test_id=test_id, hypothesis_id=hypothesis_id, reason=reason, text=text or None + ) + ) + + def note(cell: tuple[str, str, str], row: FilledRow, outcome: TableFillOutcome) -> None: + """What was said for an asked cell; a kept answer replaces a dropped one only.""" + before = said.get(cell) + if before is not None and not (before.outcome == "DROPPED" and outcome != "DROPPED"): + return + label = _label(row.expected) + reason = (row.unknown_reason or "").strip()[:200] if label == UNKNOWN else "" + said[cell] = TableFillAnswer( + test_id=cell[1], + hypothesis_id=cell[2], + expected=label[:MAX_LABEL_CHARS], + unknown_reason=reason or None, + outcome=outcome, + ) + + for item in fill.tests: + key = (item.designed_for, item.test_id) + if key not in tests: + drop(item.designed_for, item.test_id, "", "UNKNOWN_TEST") + continue + open_ids = tests[key][2] + answers = kept.setdefault(key, {}) + for row in item.rows: + cell = (item.designed_for, item.test_id, row.hypothesis_id) + reason = _row_problem(row, open_ids, known, problem) + if reason is None and row.hypothesis_id in answers: + reason = "DUPLICATE_HYPOTHESIS" + if reason is None and not set(row.basis) <= known_spans: + reason = "UNKNOWN_SPAN" + if reason is not None: + text = row.expected if reason.startswith("EXPECTED_") else "" + drop(item.designed_for, item.test_id, row.hypothesis_id, reason, text) + if row.hypothesis_id in open_ids: + note(cell, row, "DROPPED") + continue + answers[row.hypothesis_id] = ExpectedResult( + hypothesis_id=row.hypothesis_id, + expected=_label(row.expected), + basis=row.basis[:MAX_BASIS], + ) + note(cell, row, "STILL_UNKNOWN" if _label(row.expected) == UNKNOWN else "CHANGED") + changed = 0 + settled: list[HypothesisV3] = [] + for owner in portfolio.hypotheses: + new_tests: list[DiscriminatingTestV3] = [] + for test in owner.discriminating_tests: + answers = kept.get((owner.hypothesis_id, test.test_id), {}) + rows = list(test.expected_by_hypothesis) + at = {row.hypothesis_id: index for index, row in enumerate(rows)} + for hypothesis_id in sorted(answers, key=position.__getitem__): + answer = answers[hypothesis_id] + if hypothesis_id in at: + if answer.expected != UNKNOWN: + rows[at[hypothesis_id]] = answer + changed += 1 + elif len(rows) < MAX_ROWS: + rows.append(answer) + changed += answer.expected != UNKNOWN + else: + drop(owner.hypothesis_id, test.test_id, hypothesis_id, "TABLE_ROW_LIMIT") + cell = (owner.hypothesis_id, test.test_id, hypothesis_id) + said[cell] = said[cell].model_copy(update={"outcome": "DROPPED"}) + new_tests.append(test.model_copy(update={"expected_by_hypothesis": tuple(rows)})) + changed_owner = owner.model_copy(update={"discriminating_tests": tuple(new_tests)}) + changed_owner.note_dropped((*owner.dropped, *dropped.get(owner.hypothesis_id, ()))) + settled.append(changed_owner) + return ( + portfolio.model_copy(update={"hypotheses": tuple(settled)}), + changed, + _answers(portfolio, tests, said), + ) + + +def _answers( + portfolio: HypothesisPortfolioV3, + tests: dict[tuple[str, str], tuple[HypothesisV3, DiscriminatingTestV3, set[str]]], + said: dict[tuple[str, str, str], TableFillAnswer], +) -> tuple[TableFillAnswer, ...]: + """One entry for every cell the filler was asked about, in the portfolio's order; a cell it + gave no row for is NOT_ANSWERED.""" + found: list[TableFillAnswer] = [] + for (owner_id, test_id), (_, _, open_ids) in tests.items(): + for item in portfolio.hypotheses: + if item.hypothesis_id in open_ids: + found.append( + said.get((owner_id, test_id, item.hypothesis_id)) + or TableFillAnswer( + test_id=test_id, hypothesis_id=item.hypothesis_id, outcome="NOT_ANSWERED" + ) + ) + return tuple(found) + + +def _noted(portfolio: HypothesisPortfolioV3, record: TableFillRecord) -> HypothesisPortfolioV3: + result = portfolio.model_copy() + result.note_table_fill(record) + work = research_work.get() + if work is not None: + work.result_only["hypothesis_table_fill"] = record.model_dump(mode="json") + return result + + +async def fill_tables( + model: ModelPort, + portfolio: HypothesisPortfolio, + context: ContextPack, + command: CycleInput, +) -> HypothesisPortfolio: + """The portfolio with its open cells filled, when it is a v3 portfolio that has any.""" + if not isinstance(portfolio, HypothesisPortfolioV3) or len(portfolio.hypotheses) < 2: + return portfolio + cells, open_before = count_cells(portfolio) + if cells == 0: + return portfolio + base = TableFillRecord(state="SKIPPED_FULL", cells=cells, open_before=open_before) + if not _payload(portfolio)["tests"]: + return _noted(portfolio, base.model_copy(update={"open_after": open_before})) + try: + result = await model.structured( + ModelRequest( + role=ModelRole.HYPOTHESIS_TABLE_FILLER, + project_id=context.project_id, + cutoff_at=command.cutoff_at, + context_pack=_request_context(context, portfolio), + output_model=HypothesisTableFill, + prompt_version=TABLE_FILLER_VERSION, + model_policy_ref=command.model_policy_ref, + max_output_tokens=TABLE_FILLER_MAX_OUTPUT_TOKENS, + ) + ) + except (ModelExecutionHold, ValidationError) as error: + reason = UNUSABLE_ANSWER if isinstance(error, ValidationError) else str(error) + state = "SKIPPED_BUDGET" if reason in BUDGET_HOLDS else "FAILED" + failed = base.model_copy( + update={"state": state, "reason_code": reason[:200], "open_after": open_before} + ) + return _noted(portfolio, failed) + merged, changed, answers = merge_fill( + portfolio, result.output, {span.span_id for span in command.evidence}, context.problem + ) + _, open_after = count_cells(merged) + called = base.model_copy( + update={ + "state": "CALLED", + "open_after": open_after, + "changed_cells": changed, + "answers": answers, + } + ) + return _noted(merged, called) + + +async def accepted_portfolio( + model: ModelPort, + generated: HypothesisPortfolio, + context: ContextPack, + command: CycleInput, + require_unknown: bool, +) -> HypothesisPortfolio: + """The generator's output settled, its open table cells filled (v3), and validated.""" + settled = settle_contract(generated, command.evidence, context.problem) + filled = await fill_tables(model, settled, context, command) + return validate_hypothesis_portfolio( + filled, evidence=command.evidence, require_unknown_alternative=require_unknown + ) diff --git a/src/thoth/application/services/memory_query_expansion.py b/src/thoth/application/services/memory_query_expansion.py index ec146d2..f95ea1c 100644 --- a/src/thoth/application/services/memory_query_expansion.py +++ b/src/thoth/application/services/memory_query_expansion.py @@ -28,7 +28,7 @@ from thoth.ports.memory import MemoryQueryExpanderPort from thoth.ports.model import ModelOutputContractHold, ModelPort -PROMPT_VERSION = "memory_query_expansion.v1" +PROMPT_VERSION = "memory_query_expansion.v2" EXPANSION_MAX_OUTPUT_TOKENS = 300 EXPANSION_TIMEOUT_SECONDS = 20.0 CACHE_KEY = "memory_query_expansion" diff --git a/src/thoth/application/services/memory_relation_judge.py b/src/thoth/application/services/memory_relation_judge.py index ee6f3e4..0511bfe 100644 --- a/src/thoth/application/services/memory_relation_judge.py +++ b/src/thoth/application/services/memory_relation_judge.py @@ -14,7 +14,7 @@ from thoth.domain.model import ContextPack, ModelRequest from thoth.ports.model import ModelPort -PROMPT_VERSION = "memory_relation.v1" +PROMPT_VERSION = "memory_relation.v2" _TASK = ( "Two stored project memories are shown in the given order. Say how they relate: DUPLICATE, " "CONTAINS, UPDATE, CONTRADICTION_CANDIDATE, UNRELATED or AMBIGUOUS. Quote, word for word, " diff --git a/src/thoth/application/services/research_analysis.py b/src/thoth/application/services/research_analysis.py index 3acdcbd..5bd31b7 100644 --- a/src/thoth/application/services/research_analysis.py +++ b/src/thoth/application/services/research_analysis.py @@ -15,7 +15,7 @@ from thoth.application.services.research_coverage import answer_assessment_state, assess_coverage from thoth.application.services.research_findings import adopt_confirmed_findings from thoth.application.services.research_gaps import gap_targets, validate_gaps -from thoth.application.services.research_retrieval import lexical_candidates +from thoth.application.services.research_retrieval import pinned_first, work_candidates from thoth.application.services.research_retrieval_policy import record_selection, retrieval_policy from thoth.application.services.research_role_context import bundle_view, role_context from thoth.application.services.research_stage_reuse import reuse_stage_if_exact @@ -84,6 +84,7 @@ def assemble( ) -> ContextAssembly: started = perf_counter_ns() work.boundary.check() + ranking = pinned_first(ranking, candidates, work.pinned_spans) # Re-read scoped structure and complete source identity before any cache reuse. key = domain_digest( "CONTEXT_ASSEMBLY_INPUT", @@ -334,7 +335,7 @@ async def run( ) # Start with admissible connected source candidates; no fake evidence is needed. all_evidence = self.evidence(project.project_id) - shortlist = lexical_candidates(work.effective_question, (), all_evidence, work.pinned_spans) + shortlist = work_candidates(work, (), all_evidence) if shortlist and all(span.cutoff_state == CutoffState.UNKNOWN_TIME for span in shortlist): work.context["source_time_limitation"] = "SOURCE_TIME_UNCONFIRMED" assembly = self.assemble(work, tuple(s.span_id for s in shortlist), shortlist, all_evidence) @@ -482,9 +483,8 @@ async def review( proposal: RequirementProposal, source: tuple[EvidenceSpan, ...], ) -> tuple[CoverageAssessment, str]: - # Relevance ranking must see the original bounded candidates. Expanding before - # ranking can spend the context budget on prose and hide the very cell to rank. - candidates = lexical_candidates(work.effective_question, proposal.expanded_queries, source) + # Rank the original bounded candidates: expanding first spends the budget on prose. + candidates = work_candidates(work, proposal.expanded_queries, source) ranked, _ = await self.ask( model, project, diff --git a/src/thoth/application/services/research_attempt_runner.py b/src/thoth/application/services/research_attempt_runner.py index a875fb7..9b7ce13 100644 --- a/src/thoth/application/services/research_attempt_runner.py +++ b/src/thoth/application/services/research_attempt_runner.py @@ -250,6 +250,7 @@ async def prepare() -> None: return host.pending(attempt, "WAITING_FOR_LEGACY_THREAD") combined = { **work.context, + **work.result_only, **result, "cleanup_usage": [ usage.model_dump(mode="json") for usage in work.cleanup_usage.values() @@ -285,7 +286,7 @@ async def prepare() -> None: request, work, "HOLD", - work.context, + {**work.context, **work.result_only}, (reason,), terminal=reason, check=False, @@ -394,7 +395,7 @@ def _prepare_work( and codex_family(provider=settings.provider, capability_source=settings.capability_source) and host.call_settings.auto_retry_interrupted(request.project_id) ): - # The project's own switch (off unless turned on): one cut-off call may be sent once more. + # The project's saved choice, otherwise on (사용자 결정 20261010-01); at most two retries. work.auto_retry_interrupted_call = True calls = host.call_settings if calls is not None and calls.hypothesis_contract_v3(request.project_id): diff --git a/src/thoth/application/services/research_retrieval.py b/src/thoth/application/services/research_retrieval.py index e1ac195..c7cbec1 100644 --- a/src/thoth/application/services/research_retrieval.py +++ b/src/thoth/application/services/research_retrieval.py @@ -11,9 +11,12 @@ retrieval_policy, ) from thoth.domain.evidence import EvidenceSpan, connected_retrieval_spans +from thoth.domain.research_execution import ResearchWork _MIN_TERM = 3 _MIN_SPAN_CHARS = 24 +# A block of short key-value lines is read together only while it stays small. +_MAX_BLOCK_SPANS = 20 def tokens(text: str) -> set[str]: @@ -31,7 +34,7 @@ def _term_hits(text: str, terms: set[str]) -> int: def _is_chrome(span: EvidenceSpan) -> bool: text = span.exact_text.strip() - if len(text) < _MIN_SPAN_CHARS: + if len(text) < _MIN_SPAN_CHARS and not _block_key(span): return True lowered = text.casefold() if lowered in {"---", "article"}: @@ -66,6 +69,15 @@ def _interleave_artifacts(ranked: list[EvidenceSpan]) -> list[EvidenceSpan]: return interleaved +def _block_key(span: EvidenceSpan) -> str | None: + """The key path that groups a structured line with the lines under the same parent key.""" + pointer = span.locator.json_pointer + if not pointer or not pointer.startswith("/"): + return None + parent, _, _ = pointer.rpartition("/") + return parent or pointer + + def text_neighbors( span: EvidenceSpan, source: tuple[EvidenceSpan, ...] ) -> tuple[EvidenceSpan, ...]: @@ -81,7 +93,19 @@ def text_neighbors( key=lambda s: (s.locator.page or 0, s.locator.line or 0, s.span_id), ) index = next(i for i, s in enumerate(siblings) if s.span_id == span.span_id) - return tuple([*siblings[:1], *siblings[max(0, index - 1) : index + 2]]) + near = [*siblings[:1], *siblings[max(0, index - 1) : index + 2]] + key = _block_key(span) + if key is None: + return tuple(near) + block = [ + s + for s in siblings + if (s.locator.json_pointer or "") == key + or (s.locator.json_pointer or "").startswith(f"{key}/") + ] + if len(block) > _MAX_BLOCK_SPANS: + return tuple(near) + return tuple(dict.fromkeys([*near, *block])) def lexical_candidates( @@ -98,9 +122,7 @@ def lexical_candidates( ranked = sorted(pool, key=lambda span: _rank_key(span, exact=exact, terms=terms)) if any(span.span_id in exact or _term_hits(span.exact_text, terms) for span in ranked): ranked = [ - span - for span in ranked - if span.span_id in exact or _term_hits(span.exact_text, terms) + span for span in ranked if span.span_id in exact or _term_hits(span.exact_text, terms) ] # A bounded shortlist, not a claim that unselected source territory was searched. # A pinned span is kept first when it is retrievable at all; it never widens what is eligible. @@ -125,6 +147,29 @@ def lexical_candidates( return result +def work_candidates( + work: ResearchWork, queries: tuple[str, ...], evidence: tuple[EvidenceSpan, ...] +) -> tuple[EvidenceSpan, ...]: + """The shortlist for a request: its question, its queries and the spans its start row pins.""" + return lexical_candidates(work.effective_question, queries, evidence, work.pinned_spans) + + +def pinned_first( + ranking: tuple[str, ...], candidates: tuple[EvidenceSpan, ...], pinned: tuple[str, ...] +) -> tuple[str, ...]: + """The ranking with the pinned candidates the model left out put in front of it. + + A pinned span is the starting row's own evidence, so a model's ordering cannot drop it. An id + the model repeated is left as it was: the assembly still refuses it. + """ + left_out = tuple( + span.span_id + for span in candidates + if span.span_id in pinned and span.span_id not in ranking + ) + return (*left_out, *ranking) + + def adjacent_packet( ranking: tuple[str, ...], candidates: tuple[EvidenceSpan, ...], diff --git a/src/thoth/application/services/text_script.py b/src/thoth/application/services/text_script.py new file mode 100644 index 0000000..5a5b9ce --- /dev/null +++ b/src/thoth/application/services/text_script.py @@ -0,0 +1,64 @@ +"""Which script a readable text is written in, for a question written in Hangul. + +A Hangul question gets Hangul text. Chinese characters and kana are a problem unless the question +or the supplied evidence has them (a quotation). A question without Hangul takes any text. The +katakana middle dot is punctuation, not a kana letter: it is never a problem, and the one thing +changed here is that a model that wrote it as a separator gets the Korean middle dot instead. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterable + +HANGUL = re.compile("[가-힣]") +HAN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") +# Kana letters only: U+30FB (the katakana middle dot) is punctuation and is left out. +KANA = re.compile(r"[\u3040-\u30fa\u30fc-\u30ff]") +_FOREIGN_RUN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30fa\u30fc-\u30ff]+") +KATAKANA_MIDDLE_DOT = "\u30fb" +KOREAN_MIDDLE_DOT = "\u00b7" + + +def korean_middle_dot(text: str) -> str: + """The text with every katakana middle dot written as the Korean middle dot.""" + return text.replace(KATAKANA_MIDDLE_DOT, KOREAN_MIDDLE_DOT) + + +def language_problem(text: str, problem: str) -> str | None: + """LANGUAGE when a Hangul question gets text with no Hangul, SCRIPT when the text has Chinese + characters or kana the question has none of, None otherwise (a question without Hangul takes + any text).""" + if not HANGUL.search(problem): + return None + if not HANGUL.search(text): + return "LANGUAGE" + foreign = HAN.search(text) or KANA.search(text) + return "SCRIPT" if foreign and not (HAN.search(problem) or KANA.search(problem)) else None + + +class Quotes: + """What the question and the supplied evidence say, read only when a text needs checking.""" + + def __init__(self, problem: str, evidence: Callable[[], Iterable[str]]) -> None: + self._problem = problem + self._evidence = evidence + self._corpus: str | None = None + + def has(self, fragment: str) -> bool: + if self._corpus is None: + self._corpus = "\n".join((self._problem, *self._evidence())) + return fragment in self._corpus + + +def visible_problem(text: str, problem: str, quotes: Quotes) -> str | None: + """language_problem, except that characters quoted from the question or the evidence are fine: + every run of Chinese characters or kana must be in what was quoted, and a text with no Hangul + is fine when it is quoted whole.""" + kind = language_problem(text, problem) + if kind == "SCRIPT": + quoted = all(quotes.has(run) for run in _FOREIGN_RUN.findall(text)) + return None if quoted else kind + if kind == "LANGUAGE": + return None if quotes.has(text.strip()) else kind + return None diff --git a/src/thoth/application/services/trace_origin.py b/src/thoth/application/services/trace_origin.py index d54141d..a95a613 100644 --- a/src/thoth/application/services/trace_origin.py +++ b/src/thoth/application/services/trace_origin.py @@ -9,10 +9,20 @@ mark_stale, ) from thoth.domain.research_execution import ResearchWork -from thoth.domain.trace_origin import MAX_ORIGIN_REFS, TraceOriginInput, TraceVerdictOrigin +from thoth.domain.trace_origin import ( + MAX_COMPARISON_REFS, + MAX_ORIGIN_REFS, + MAX_SIBLING_SPAN_REFS, + MAX_SIBLING_VERDICTS, + TraceOriginInput, + TraceSiblingVerdict, + TraceVerdictOrigin, +) from thoth.domain.verification_trace import ( + CriterionRule, SubjectKind, TraceKind, + TraceRelation, VerdictRevision, VerificationTraceRecord, subject_key, @@ -23,7 +33,9 @@ "This investigation started from one row of the project's verification trace. The verdict was " "computed by the trace rule from stored results; do not change, re-judge or soften it. Use the " "row's reasons, rule and source positions as the starting context for finding candidate causes " - "and the tests that would tell them apart." + "and the tests that would tell them apart. sibling_verdicts, when present, are the same " + "measure under the other conditions of the same requirement, computed by the same rule: " + "compare what differs between them and this row." ) @@ -52,11 +64,91 @@ def build_trace_origin( if claimed.verdict_revision in history else "TRACE_ORIGIN_NOT_FOUND" ) - return _from_stored(project_id, record, service.revision(project_id, history[-1])) + verdict = service.revision(project_id, history[-1]) + return _from_stored( + project_id, record, verdict, _siblings(service, project_id, record, verdict) + ) + + +def _siblings( + service: VerificationTraceService, + project_id: str, + record: VerificationTraceRecord, + verdict: VerdictRevision, +) -> tuple[tuple[TraceSiblingVerdict, ...], tuple[str, ...]]: + """The other conditions of this criterion's measure under its requirement, and where their + results are written. Found from the trace's links and rules only: criteria that refine the same + requirement, have a rule with the same measure and another condition. Those with a chosen result + come first, then the trace's own order; at most MAX_SIBLING_VERDICTS.""" + if verdict.subject_kind is not SubjectKind.CRITERION: + return (), () + trace_set = record.trace_set + rules = {rule.criterion_id: rule for rule in trace_set.rules} + own = rules.get(verdict.subject_id) + if own is None: + return (), () + parents = { + link.to_id + for link in trace_set.links + if link.relation is TraceRelation.REFINES and link.from_id == verdict.subject_id + } + found: list[tuple[TraceSiblingVerdict, tuple[str, ...]]] = [] + for item in trace_set.items: + if item.kind is not TraceKind.CRITERION or item.item_id == verdict.subject_id: + continue + rule = rules.get(item.item_id) + shares_parent = any( + link.relation is TraceRelation.REFINES + and link.from_id == item.item_id + and link.to_id in parents + for link in trace_set.links + ) + digests = record.history.get(subject_key(SubjectKind.CRITERION, item.item_id), ()) + if rule is None or not shares_parent or not digests: + continue + if rule.measure != own.measure or rule.condition == own.condition: + continue + found.append(_sibling(service, project_id, record, rule, digests[-1])) + found.sort(key=lambda entry: entry[0].result_id is None) # stable: the trace's order is kept + chosen = found[:MAX_SIBLING_VERDICTS] + refs = tuple(dict.fromkeys(ref for _, spans in chosen for ref in spans))[:MAX_COMPARISON_REFS] + return tuple(entry[0] for entry in chosen), refs + + +def _sibling( + service: VerificationTraceService, + project_id: str, + record: VerificationTraceRecord, + rule: CriterionRule, + digest: str, +) -> tuple[TraceSiblingVerdict, tuple[str, ...]]: + verdict = service.revision(project_id, digest) + chosen = ( + () if verdict.selection is None else tuple(c.result_id for c in verdict.selection.chosen) + ) + result = next( + (r for r in record.trace_set.results if chosen and r.result_id == chosen[-1]), None + ) + summary = f"{rule.measure} {rule.comparator.value} {format(rule.threshold, 'f')} {rule.unit}" + sibling = TraceSiblingVerdict( + criterion_id=rule.criterion_id, + condition=rule.condition[:500], + state=verdict.state.value, + rule_summary=summary[:500], + result_id=None if result is None else result.result_id[:500], + value=None if result is None or result.value is None else format(result.value, "f"), + numerator=None if result is None else result.numerator, + denominator=None if result is None else result.denominator, + unit=None if result is None else result.unit[:100], + ) + return sibling, tuple(verdict.basis.source_span_refs[:MAX_SIBLING_SPAN_REFS]) def _from_stored( - project_id: str, record: VerificationTraceRecord, verdict: VerdictRevision + project_id: str, + record: VerificationTraceRecord, + verdict: VerdictRevision, + siblings: tuple[tuple[TraceSiblingVerdict, ...], tuple[str, ...]] = ((), ()), ) -> TraceVerdictOrigin: trace_set = record.trace_set title = next( @@ -92,6 +184,8 @@ def _from_stored( rule_summary=None if summary is None else summary.strip(), chosen_result_ids=chosen[:MAX_ORIGIN_REFS], source_span_refs=tuple(verdict.basis.source_span_refs[:MAX_ORIGIN_REFS]), + sibling_verdicts=siblings[0], + comparison_span_refs=siblings[1], ) @@ -120,4 +214,6 @@ def apply_trace_origin( work.context["origin_rejected"] = "TRACE_ORIGIN_PROJECT_MISMATCH" return work.context["origin"] = origin_context(origin) - work.pinned_spans = origin.source_span_refs + work.pinned_spans = tuple( + dict.fromkeys((*origin.source_span_refs, *origin.comparison_span_refs)) + ) diff --git a/src/thoth/application/workflows/thread_cycle.py b/src/thoth/application/workflows/thread_cycle.py index 32c8ccd..673e238 100644 --- a/src/thoth/application/workflows/thread_cycle.py +++ b/src/thoth/application/workflows/thread_cycle.py @@ -1,889 +1,872 @@ -from __future__ import annotations - -from contextlib import nullcontext -from dataclasses import dataclass, field -from datetime import datetime -from typing import Literal - -from pydantic import model_validator - -from thoth.application.reducers import ( - SufficiencySignals, - assess_information_sufficiency, - validate_hypothesis_portfolio, -) -from thoth.application.services.action_compiler import compile_action_plan -from thoth.application.services.behavior_context import ( - require_compiled_behavior_plan, - should_semantic_repair, -) -from thoth.application.services.criterion_projection import criterion_projection -from thoth.application.services.cycle_memory_preparation import prepare_cycle_memory -from thoth.application.services.full_project_memory import ( - FullMemoryPromotionResult, - FullProjectMemoryService, -) -from thoth.application.services.hypothesis_contract_v3 import generator_contract, settle_contract -from thoth.application.services.memory_relation_resolver import MemoryRelationBudget -from thoth.application.services.research_commit_scope import ReadScope, read_scope -from thoth.application.services.research_identity_service import ( - HypothesisOwnershipBatch, - ResearchContext, - ResearchIdentityService, - ResearchOwnershipBatch, -) -from thoth.application.services.revision_service import ( - CommitDisposition, - CommitResult, - RevisionCommitService, -) -from thoth.domain.action import ( - ActionCompilationPolicy, - ActionPlan, - ActionPlanCompilationResult, - ActionPlanDraft, -) -from thoth.domain.actor import ActorRef -from thoth.domain.base import DomainModel -from thoth.domain.canonical import canonical_payload, domain_digest, head_set_digest -from thoth.domain.criterion import CriterionCandidate -from thoth.domain.criterion_contract import CriterionContractRecord -from thoth.domain.enums import ( - EntityType, - HypothesisStatus, - ModelRole, - OutcomeStatus, - PortfolioStatus, - SufficiencyStatus, -) -from thoth.domain.evidence import EvidenceSpan, InformationSufficiencyAssessment -from thoth.domain.evidence_requirements import ( - HypothesisSemanticReview, - HypothesisSemanticReviewRecord, -) -from thoth.domain.hypothesis import HypothesisPortfolio -from thoth.domain.hypothesis_review_validation import review_coverage, target_ids -from thoth.domain.memory import MemoryRecord -from thoth.domain.memory_preparation import ( - MemoryPreparationBasis, - MemoryPreparationStale, - PreparedMemoryPromotion, -) -from thoth.domain.model import ContextPack, ModelRequest, ModelResult -from thoth.domain.outcome import OutcomeRecord -from thoth.domain.relation import DependencyRelation -from thoth.domain.research_execution import ResearchWork, check_research_boundary, research_work -from thoth.domain.research_identity import ResearchIdentityError, decode_research -from thoth.domain.revision import ( - EntitySnapshot, - ImpactPropagationPlan, - RevisionChangeSet, - SemanticRevision, - StagedRevision, -) -from thoth.ports.dependency import DependencyGraphPort -from thoth.ports.ledger import LedgerPort -from thoth.ports.memory import MemoryStorePort -from thoth.ports.model import ModelOutputContractHold, ModelPort -from thoth.ports.runtime import AtomicUnitOfWorkPort, ClockPort, IdGeneratorPort - -ACTION_PLAN_MAX_OUTPUT_TOKENS = 12_000 - - -@dataclass(frozen=True) -class ThreadCycleCommand: - case_id: str - project_id: str - thread_id: str - object_id: str - problem: str - cutoff_at: datetime - criteria: tuple[CriterionCandidate, ...] - evidence: tuple[EvidenceSpan, ...] - sufficiency_signals: SufficiencySignals - action_policy: ActionCompilationPolicy - actor: ActorRef - policy_version: str - model_policy_ref: str - expected_head_overrides: dict[str, str] = field(default_factory=dict) - memory_scope: dict[str, str] = field(default_factory=dict) - - -class ThreadCycleResult(DomainModel): - assessment: InformationSufficiencyAssessment - portfolio: HypothesisPortfolio - action_plan: ActionPlan - action_compilation: ActionPlanCompilationResult - outcome: OutcomeRecord | None = None - commit: CommitResult - memory_ids: tuple[str, ...] - model_ids: tuple[str, ...] - full_memory: FullMemoryPromotionResult | None = None - semantic_repair_attempted: bool = False - - -class ThreadCycleBranchResult(DomainModel): - project_id: str - thread_id: str - terminal_state: Literal["BRANCHED"] = "BRANCHED" - reason_code: Literal["THREAD_CYCLE_HEAD_CONFLICT"] = "THREAD_CYCLE_HEAD_CONFLICT" - commit: CommitResult - downstream_after_branch_executed: Literal[False] = False - prior_acquisition_completed: bool = False - input_disposition: Literal["RETAINED_FOR_EXPLICIT_RETRY"] = "RETAINED_FOR_EXPLICIT_RETRY" - - @model_validator(mode="after") - def require_branch_commit(self) -> ThreadCycleBranchResult: - if self.commit.disposition != CommitDisposition.BRANCH: - raise ValueError("branch result requires a branch commit") - return self - - -class ThreadCycleService: - def __init__( - self, - *, - ledger: LedgerPort, - memory: MemoryStorePort, - model: ModelPort, - commits: RevisionCommitService, - clock: ClockPort, - ids: IdGeneratorPort, - dependencies: DependencyGraphPort, - research: ResearchIdentityService, - unit_of_work: AtomicUnitOfWorkPort | None = None, - full_memory: FullProjectMemoryService | None = None, - ) -> None: - self._ledger = ledger - self._memory = memory - self._model = model - self._commits = commits - self._clock = clock - self._ids = ids - self._dependencies = dependencies - self._research = research - self._unit_of_work = unit_of_work - self._full_memory = full_memory - - async def execute(self, command: ThreadCycleCommand) -> ThreadCycleResult: - work = research_work.get() - check_research_boundary() - heads = dict(self._ledger.read_heads(command.project_id)) - head_digest = domain_digest("WORKING_HEADS", "1.0.0", canonical_payload(heads)) - memory_basis = ( - None - if self._full_memory is None - else self._full_memory.capture_basis( - project_id=command.project_id, - cutoff_at=command.cutoff_at, - scope=command.memory_scope, - actor=command.actor, - ) - ) - research = self._research.context(command.project_id, command.object_id, heads) - criterion_staged = self._stage_criteria(command, heads) - previous_portfolio, previous_action_plan = ( - research.portfolio_view, - research.action_plan_view, - ) - assessment = assess_information_sufficiency( - assessment_id=self._ids.new("assessment"), - assessment_revision_id=self._ids.new("assessment-revision"), - project_id=command.project_id, - target_object_id=command.object_id, - cutoff_at=command.cutoff_at, - decision_question=command.problem, - criteria=command.criteria, - evidence=command.evidence, - signals=command.sufficiency_signals, - policy_version=command.policy_version, - input_head_set_digest=head_digest, - ) - context = self._build_model_context(command, assessment, head_digest, research) - if work is not None: - context = context.model_copy(update={"research_context": work.context}) - hypothesis_result = await self._model.structured( - ModelRequest( - role=ModelRole.HYPOTHESIS_GENERATOR, - project_id=command.project_id, - cutoff_at=command.cutoff_at, - context_pack=context, - **generator_contract(work is not None and work.hypothesis_contract_v3), - model_policy_ref=command.model_policy_ref, - max_output_tokens=4_000, - ) - ) - require_unknown = any( - status - in { - SufficiencyStatus.EVIDENCE_ACQUISITION_REQUIRED, - SufficiencyStatus.EXPERT_INPUT_REQUIRED, - } - for status in assessment.derived_status - ) - portfolio = validate_hypothesis_portfolio( - settle_contract(hypothesis_result.output, command.evidence), - evidence=command.evidence, - require_unknown_alternative=require_unknown, - ) - review_id = self._ids.new("hypothesis-semantic-review") if work is not None else None - if work is not None: - # A generator cannot grant epistemic or empirical promotion to itself. - portfolio = portfolio.model_copy( - update={ - "status": PortfolioStatus.DRAFT, - "hypotheses": tuple( - h.model_copy( - update={ - "status": HypothesisStatus.DRAFT, - "semantic_review_ref": review_id, - } - ) - for h in portfolio.hypotheses - ), - } - ) - if portfolio.generated_from_head_set != head_digest: - raise ValueError("hypothesis portfolio was generated from a different head set") - if ( - previous_portfolio is not None - and portfolio.portfolio_id != previous_portfolio.portfolio_id - ): - raise ValueError("reanalysis must preserve the existing hypothesis portfolio ID") - if self._full_memory is not None and memory_basis is not None: - self._full_memory.require_authority(memory_basis) - hypothesis_batch = ( - None - if work is None - else self._research.stage_hypotheses(portfolio, actor=command.actor, context=research) - ) - hypothesis_review = None - if work is not None: - assert hypothesis_batch is not None - hypothesis_review, context = await self._review_hypotheses( - command, context, portfolio, hypothesis_batch, work - ) - action_result, action_compilation, semantic_repair_attempted = await self._plan_actions( - command, context, portfolio, memory_basis - ) - plan = require_compiled_behavior_plan(action_compilation.plan) - if work is not None: - from thoth.application.services.research_decision_gates import qualify_action_frontier - - plan = qualify_action_frontier(plan, work) - if work is not None and work.show_draft is not None: - work.show_draft( - "ACTION_COMPARISON", - { - "state": "DRAFT_NOT_COMMITTED", - "action_plan": plan.model_dump(mode="json"), - "hypothesis_review": work.context.get("hypothesis_review"), - }, - ) - if plan.plan_revision_digest != head_digest: - raise ValueError("action plan was generated from a different head set") - if previous_action_plan is not None and plan.plan_id != previous_action_plan.plan_id: - raise ValueError("reanalysis must preserve the existing action plan ID") - research_batch = ( - self._research.stage_cycle(portfolio, plan, actor=command.actor, context=research) - if hypothesis_batch is None - else ResearchOwnershipBatch( - hypothesis_batch, - self._research.stage_actions( - plan, - actor=command.actor, - context=research, - hypotheses=hypothesis_batch.hypotheses, - ), - ) - ) - staged_items = [ - self._stage( - command, - EntityType.EVIDENCE, - assessment.assessment_id, - assessment.model_dump(mode="python"), - command.evidence, - heads, - ), - research_batch.hypotheses.staged[0], - research_batch.actions.staged[0], - ] - outcome = None - if previous_portfolio is not None or previous_action_plan is not None: - outcome = self._reanalysis_outcome(command, plan, head_digest) - staged_items.append( - self._stage( - command, - EntityType.OUTCOME, - outcome.outcome_id, - outcome.model_dump(mode="python"), - command.evidence, - heads, - ) - ) - staged_items.extend(criterion_staged) - if hypothesis_review is not None and review_id is not None and work is not None: - assert hypothesis_batch is not None - staged_items.append( - self._stage( - command, - EntityType.EVIDENCE, - review_id, - HypothesisSemanticReviewRecord( - request_ref=work.request_ref, - target_revision_digests=tuple( - h.revision_digest for h in hypothesis_batch.hypotheses - ), - portfolio_revision_digest=hypothesis_batch.portfolio.revision_digest, - review=hypothesis_review, - ).model_dump(mode="python"), - command.evidence, - heads, - ) - ) - staged = (*staged_items, *research_batch.additional_staged) - staged_keys = { - f"{item.revision.entity_type.value}:{item.revision.entity_id}" for item in staged - } - expected_heads = {key: value for key, value in heads.items() if key in staged_keys} - expected_heads.update(command.expected_head_overrides or {}) - commit, memories, full_memory_result = await self._commit_cycle( - command=command, - heads=heads, - expected_heads=expected_heads, - staged=staged, - assessment=assessment, - portfolio=portfolio, - plan=plan, - outcome=outcome, - memory_basis=memory_basis, - research_batch=research_batch, - ) - return ThreadCycleResult( - assessment=assessment, - portfolio=portfolio, - action_plan=plan, - action_compilation=action_compilation, - outcome=outcome, - commit=commit, - memory_ids=tuple(record.memory_id for record in memories), - model_ids=(hypothesis_result.model_id, action_result.model_id), - full_memory=full_memory_result, - semantic_repair_attempted=semantic_repair_attempted, - ) - - def _reanalysis_outcome( - self, command: ThreadCycleCommand, plan: ActionPlan, head_digest: str - ) -> OutcomeRecord: - return OutcomeRecord( - outcome_id=f"outcome:{command.thread_id}", - project_id=command.project_id, - action_id=plan.frontier[0] if plan.frontier else plan.alternatives[0].action_id, - status=OutcomeStatus.OBSERVED, - observed_evidence_refs=tuple(span.span_id for span in command.evidence), - hypothesis_updates=(), - interpretation=( - "Reanalysis was recorded after connected evidence changed; causal attribution " - "has not been independently established." - ), - limitations=("This outcome record does not certify causality or scientific truth.",), - recorded_at=self._clock.now(), - input_head_set_digest=head_digest, - ) - - async def _review_hypotheses( - self, - command: ThreadCycleCommand, - context: ContextPack, - portfolio: HypothesisPortfolio, - hypothesis_batch: HypothesisOwnershipBatch, - work: ResearchWork, - ) -> tuple[HypothesisSemanticReview, ContextPack]: - assert hypothesis_batch is not None - if work.show_draft is not None: - work.show_draft( - "HYPOTHESIS_REVIEW", - { - "state": "DRAFT_NOT_COMMITTED", - "portfolio": portfolio.model_dump(mode="json"), - "proposed_revision_refs": [ - h.revision_digest for h in hypothesis_batch.hypotheses - ], - }, - ) - review_result = await self._model.structured( - ModelRequest( - role=ModelRole.HYPOTHESIS_REVIEWER, - project_id=command.project_id, - cutoff_at=command.cutoff_at, - context_pack=context.model_copy( - update={ - "candidate_portfolio": portfolio, - "research_context": { - **work.context, - "hypothesis_review_target_ids": list(target_ids(portfolio)), - "task": "Review the exact proposed full revisions. " - "Return exactly one decision for every input hypothesis_id; " - "if evidence is insufficient for an ID, mark that ID INCONCLUSIVE. " - "Evaluate entailment, counterevidence, uncertainty " - "and discriminating tests. Zero or one hypothesis is allowed; " - "preserve alternatives considered and next checks. " - "Do not invent 11-axis scores, independent evaluation " - "or human calibration. " - "Keep promotion HOLD where profile requirements are unverified.", - "proposed_full_hypotheses": hypothesis_batch.hypotheses, - "proposed_full_portfolio": hypothesis_batch.portfolio, - }, - } - ), - output_model=HypothesisSemanticReview, - prompt_version="hypothesis_semantic_review.v5", - model_policy_ref=command.model_policy_ref, - max_output_tokens=5000, - ) - ) - from thoth.application.services.research_decision_gates import qualify_hypothesis_review - - hypothesis_review = qualify_hypothesis_review(review_result.output, work) - coverage = review_coverage(portfolio, hypothesis_review.decisions) - if not coverage.ok: - raise ModelOutputContractHold("HYPOTHESIS_REVIEW_COVERAGE_MISMATCH") - if any( - set(d.evidence_refs) - {s.span_id for s in command.evidence} - for d in hypothesis_review.decisions - ): - raise ValueError("HYPOTHESIS_REVIEW_UNKNOWN_EVIDENCE") - work.context["hypothesis_review"] = hypothesis_review.model_dump(mode="json") - work.context["hypothesis_revision_refs"] = [ - h.revision_digest for h in hypothesis_batch.hypotheses - ] - context = context.model_copy(update={"research_context": work.context}) - return hypothesis_review, context - - async def _plan_actions( - self, - command: ThreadCycleCommand, - context: ContextPack, - portfolio: HypothesisPortfolio, - memory_basis: MemoryPreparationBasis | None, - ) -> tuple[ModelResult[ActionPlanDraft], ActionPlanCompilationResult, bool]: - action_result = await self._model.structured( - ModelRequest( - role=ModelRole.ACTION_PLANNER, - project_id=command.project_id, - cutoff_at=command.cutoff_at, - context_pack=context.model_copy(update={"candidate_portfolio": portfolio}), - output_model=ActionPlanDraft, - prompt_version="action_alternatives.v3", - model_policy_ref=command.model_policy_ref, - max_output_tokens=ACTION_PLAN_MAX_OUTPUT_TOKENS, - ) - ) - semantic_repair_attempted = False - action_compilation = compile_action_plan( - action_result.output, - evidence_ids=frozenset(span.span_id for span in command.evidence), - hypothesis_ids=frozenset( - hypothesis.hypothesis_id for hypothesis in portfolio.hypotheses - ), - policy=command.action_policy, - ) - if should_semantic_repair(action_compilation.plan is None): - semantic_repair_attempted = True - repair_context = context.model_copy( - update={ - "policy_hints": { - **context.policy_hints, - "semantic_repair_attempt": 1, - "action_compilation_hold_reasons": action_compilation.hold_reasons, - "allowed_evidence_ids": tuple(span.span_id for span in command.evidence), - "allowed_hypothesis_ids": tuple( - hypothesis.hypothesis_id for hypothesis in portfolio.hypotheses - ), - "repair_contract": ( - "Replace the rejected draft. Preserve no rejected action IDs unless " - "they can be made valid. Use only allowed IDs, at least two materially " - "different action families, and at least one safe R0-R2 frontier item." - ), - } - } - ) - if self._full_memory is not None and memory_basis is not None: - self._full_memory.require_authority(memory_basis) - repair_result = await self._model.structured( - ModelRequest( - role=ModelRole.ACTION_PLANNER, - project_id=command.project_id, - cutoff_at=command.cutoff_at, - context_pack=repair_context.model_copy( - update={"candidate_portfolio": portfolio} - ), - output_model=ActionPlanDraft, - prompt_version="action_alternatives.semantic_repair.v3", - model_policy_ref=command.model_policy_ref, - max_output_tokens=ACTION_PLAN_MAX_OUTPUT_TOKENS, - ) - ) - action_compilation = compile_action_plan( - repair_result.output, - evidence_ids=frozenset(span.span_id for span in command.evidence), - hypothesis_ids=frozenset( - hypothesis.hypothesis_id for hypothesis in portfolio.hypotheses - ), - policy=command.action_policy, - ) - if action_compilation.plan is None: - raise ValueError( - "action plan compilation held after one semantic repair: " - + "; ".join(action_compilation.hold_reasons) - ) - action_result = repair_result - return action_result, action_compilation, semantic_repair_attempted - - async def _commit_cycle( - self, - *, - command: ThreadCycleCommand, - heads: dict[str, str], - expected_heads: dict[str, str], - staged: tuple[StagedRevision, ...], - assessment: InformationSufficiencyAssessment, - portfolio: HypothesisPortfolio, - plan: ActionPlan, - outcome: OutcomeRecord | None, - memory_basis: MemoryPreparationBasis | None, - research_batch: ResearchOwnershipBatch, - ) -> tuple[CommitResult, tuple[MemoryRecord, ...], FullMemoryPromotionResult | None]: - memories = self._prepare_memory(command.project_id, staged) - # With a research attempt on record, compare what it read and wrote, not every head. - scope = read_scope( - research_work.get(), - command.project_id, - heads, - (f"{item.revision.entity_type.value}:{item.revision.entity_id}" for item in staged), - ) - if scope is not None: - expected_heads = {**scope.expected_heads, **expected_heads} - attempt = 1 - relations = MemoryRelationBudget() - while True: - try: - prepared = await prepare_cycle_memory( - self._full_memory, - thread_id=command.thread_id, - basis=memory_basis, - scope=scope, - expected_heads=expected_heads, - candidates=memories, - staged=staged, - attempt=attempt, - relations=relations, - ) - commit, memories, full_memory_result = self._save_cycle( - command=command, - heads=heads, - expected_heads=expected_heads, - scope=scope, - staged=staged, - assessment=assessment, - portfolio=portfolio, - plan=plan, - outcome=outcome, - memory_basis=memory_basis, - research_batch=research_batch, - memories=memories, - prepared=prepared, - ) - break - except MemoryPreparationStale: - # Memory was added after the review (a user correction, another investigation). - # The save was rolled back whole, so one fresh review and save cannot duplicate it. - if attempt >= 2: - raise - attempt += 1 - if commit.disposition == CommitDisposition.FAST_FORWARD: - from thoth.application.services.research_basis_capture import capture_cycle_produced - - capture_cycle_produced(staged, commit.receipt.receipt_id) - return commit, memories, full_memory_result - - def _save_cycle( - self, - *, - command: ThreadCycleCommand, - heads: dict[str, str], - expected_heads: dict[str, str], - scope: ReadScope | None, - staged: tuple[StagedRevision, ...], - assessment: InformationSufficiencyAssessment, - portfolio: HypothesisPortfolio, - plan: ActionPlan, - outcome: OutcomeRecord | None, - memory_basis: MemoryPreparationBasis | None, - research_batch: ResearchOwnershipBatch, - memories: tuple[MemoryRecord, ...], - prepared: PreparedMemoryPromotion | None, - ) -> tuple[CommitResult, tuple[MemoryRecord, ...], FullMemoryPromotionResult | None]: - """One transaction: the ledger commit, the research records and the prepared memory.""" - - full_memory_result: FullMemoryPromotionResult | None = None - with ( - self._ledger.transaction(), - self._unit_of_work.transaction() if self._unit_of_work is not None else nullcontext(), - ): - check_research_boundary() - if self._full_memory is not None and memory_basis is not None: - self._full_memory.require_authority(memory_basis) - commit = self._commits.commit( - RevisionChangeSet( - changeset_id=self._ids.new("changeset"), - project_id=command.project_id, - expected_heads=expected_heads, - expected_absent_heads=() if scope is None else scope.expected_absent, - expected_head_set_digest=None if scope is not None else head_set_digest(heads), - staged_revisions=staged, - impact_plan=ImpactPropagationPlan(), - actor=command.actor, - reason=f"thread cycle {command.thread_id}", - ) - ) - hypothesis_ref = f"HYPOTHESIS:{portfolio.portfolio_id}" - action_ref = f"ACTION:{plan.plan_id}" - outcome_ref = None if outcome is None else f"OUTCOME:{outcome.outcome_id}" - if commit.disposition == CommitDisposition.FAST_FORWARD: - self._research.persist_cycle(research_batch) - self._record_dependencies( - command=command, - assessment=assessment, - hypothesis_ref=hypothesis_ref, - action_ref=action_ref, - outcome_ref=outcome_ref, - staged=staged, - ) - if self._full_memory is None: - for record in memories: - self._memory.add(record) - elif prepared is None: - raise ValueError("MEMORY_PREPARATION_REQUIRED") - else: - full_memory_result = self._full_memory.commit_prepared( - prepared, - expected_head=commit.after_head_set_digest, - ) - else: - memories = () - return commit, memories, full_memory_result - - def _record_dependencies( - self, - *, - command: ThreadCycleCommand, - assessment: InformationSufficiencyAssessment, - hypothesis_ref: str, - action_ref: str, - outcome_ref: str | None, - staged: tuple[StagedRevision, ...], - ) -> None: - self._dependencies.add( - DependencyRelation( - relation_id=self._ids.new("relation"), - project_id=command.project_id, - source_ref=f"EVIDENCE:{assessment.assessment_id}", - relation_type="DERIVES", - target_ref=hypothesis_ref, - revision_digest=staged[1].revision.revision_digest, - ) - ) - self._dependencies.add( - DependencyRelation( - relation_id=self._ids.new("relation"), - project_id=command.project_id, - source_ref=hypothesis_ref, - relation_type="DERIVES", - target_ref=action_ref, - revision_digest=staged[2].revision.revision_digest, - ) - ) - if outcome_ref is not None: - self._dependencies.add( - DependencyRelation( - relation_id=self._ids.new("relation"), - project_id=command.project_id, - source_ref=action_ref, - relation_type="DERIVES", - target_ref=outcome_ref, - revision_digest=staged[3].revision.revision_digest, - ) - ) - work = research_work.get() - if work is not None and work.context.get("answer_status") == "PARTIAL_HOLD": - return - self._dependencies.mark_current( - command.project_id, - tuple( - f"{item.revision.entity_type.value}:{item.revision.entity_id}" for item in staged - ), - caused_by_revision=staged[-1].revision.revision_digest, - updated_at=self._clock.now().isoformat(), - ) - - @staticmethod - def _build_model_context( - command: ThreadCycleCommand, - assessment: InformationSufficiencyAssessment, - head_digest: str, - research: ResearchContext, - ) -> ContextPack: - return ContextPack( - case_id=command.case_id, - project_id=command.project_id, - object_id=command.object_id, - problem=command.problem, - evidence=command.evidence, - criteria=command.criteria, - sufficiency=assessment, - input_head_set_digest=head_digest, - policy_hints={ - "minimum_action_tier_by_family": { - family: tier.value - for family, tier in command.action_policy.minimum_tier_by_family.items() - }, - "approver_role_by_family": command.action_policy.approver_role_by_family, - "unknown_family_tier": command.action_policy.unknown_family_tier.value, - }, - research_context={ - "entity_currentness": { - key: value.model_dump(mode="json") - for key, value in research.currentness.items() - } - }, - previous_portfolio=research.portfolio_view, - previous_action_plan=research.action_plan_view, - canonical_hypotheses=research.hypotheses, - canonical_portfolio=research.portfolio, - canonical_actions=research.actions, - canonical_action_plan=research.action_plan, - canonical_test_assessments=research.test_assessments, - unresolved_research_refs=research.unrepresented_refs, - ) - - def _stage_criteria( - self, command: ThreadCycleCommand, heads: dict[str, str] - ) -> tuple[StagedRevision, ...]: - staged: list[StagedRevision] = [] - for candidate in command.criteria: - if candidate.project_id != command.project_id: - raise ResearchIdentityError("CRITERION_PROJECT_SCOPE_MISMATCH") - head = heads.get(f"CRITERION:{candidate.criterion_id}") - if candidate.source_contract_revision is not None: - if head != candidate.source_contract_revision: - raise ResearchIdentityError("CRITERION_OWNER_REVISION_CHANGED") - revision = self._ledger.read_revision_by_digest( - command.project_id, candidate.source_contract_revision - ) - snapshot = ( - None if revision is None else self._ledger.read_snapshot(revision.snapshot_id) - ) - if revision is None or snapshot is None: - raise ResearchIdentityError("CRITERION_OWNER_MISSING") - record = decode_research(revision, snapshot).record - if ( - not isinstance(record, CriterionContractRecord) - or criterion_projection(record) != candidate - ): - raise ResearchIdentityError("CRITERION_OWNER_VIEW_MISMATCH") - from thoth.application.services.research_basis_capture import capture_consumed - - capture_consumed( - command.project_id, - f"CRITERION:{candidate.criterion_id}", - revision.revision_digest, - ) - continue - if head is not None: - revision = self._ledger.read_revision_by_digest(command.project_id, head) - snapshot = ( - None if revision is None else self._ledger.read_snapshot(revision.snapshot_id) - ) - if revision is None or snapshot is None: - raise ResearchIdentityError("CRITERION_OWNER_MISSING") - if not isinstance(decode_research(revision, snapshot).record, CriterionCandidate): - raise ResearchIdentityError("CRITERION_OWNER_BINDING_REQUIRED") - staged.append( - self._stage( - command, - EntityType.CRITERION, - candidate.criterion_id, - candidate.model_dump(mode="python"), - command.evidence, - heads, - ) - ) - return tuple(staged) - - def _stage( - self, - command: ThreadCycleCommand, - entity_type: EntityType, - entity_id: str, - content: dict[str, object], - evidence: tuple[EvidenceSpan, ...], - heads: dict[str, str], - ) -> StagedRevision: - schema_version = str(content.get("schema_version", "1.0.0")) - content_digest = domain_digest("SNAPSHOT", schema_version, canonical_payload(content)) - snapshot = EntitySnapshot( - snapshot_id=self._ids.new("snapshot"), - project_id=command.project_id, - entity_type=entity_type, - entity_id=entity_id, - schema_version=schema_version, - content=content, - content_digest=content_digest, - ) - aggregate_key = f"{entity_type.value}:{entity_id}" - current = heads.get(aggregate_key) - revision_id = self._ids.new("revision") - created_at = self._clock.now() - revision_payload: dict[str, object] = { - "revision_id": revision_id, - "project_id": command.project_id, - "entity_type": entity_type.value, - "entity_id": entity_id, - "snapshot_id": snapshot.snapshot_id, - "parent_revision_digests": [] if current is None else [current], - "actor": command.actor, - "reason": f"thread cycle {command.thread_id}", - "evidence_refs": [span.span_id for span in evidence], - "affected_refs": [], - "created_at": created_at, - "schema_version": schema_version, - } - revision = SemanticRevision.model_validate( - { - **revision_payload, - "revision_digest": domain_digest( - "SEMANTIC_REVISION", schema_version, canonical_payload(revision_payload) - ), - } - ) - return StagedRevision(snapshot=snapshot, revision=revision) - - def _prepare_memory( - self, - project_id: str, - staged: tuple[StagedRevision, ...], - ) -> tuple[MemoryRecord, ...]: - from thoth.application.services.domain_reference_memory import domain_reference_memory - - return tuple( - record - for item in staged - if ( - record := domain_reference_memory( - item.revision, self._ids.new("memory"), item.snapshot.content - ) - ) - is not None - ) +from __future__ import annotations + +from contextlib import nullcontext +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal + +from pydantic import model_validator + +from thoth.application.reducers import ( + SufficiencySignals, + assess_information_sufficiency, +) +from thoth.application.services.action_compiler import compile_action_plan +from thoth.application.services.behavior_context import ( + require_compiled_behavior_plan, + should_semantic_repair, +) +from thoth.application.services.criterion_projection import criterion_projection +from thoth.application.services.cycle_memory_preparation import prepare_cycle_memory +from thoth.application.services.full_project_memory import ( + FullMemoryPromotionResult, + FullProjectMemoryService, +) +from thoth.application.services.hypothesis_contract_v3 import generator_contract +from thoth.application.services.hypothesis_table_filler import accepted_portfolio +from thoth.application.services.memory_relation_resolver import MemoryRelationBudget +from thoth.application.services.research_commit_scope import ReadScope, read_scope +from thoth.application.services.research_identity_service import ( + HypothesisOwnershipBatch, + ResearchContext, + ResearchIdentityService, + ResearchOwnershipBatch, +) +from thoth.application.services.revision_service import ( + CommitDisposition, + CommitResult, + RevisionCommitService, +) +from thoth.application.workflows.thread_cycle_preparation import ( + draft_portfolio, + expected_cycle_heads, +) +from thoth.domain.action import ( + ActionCompilationPolicy, + ActionPlan, + ActionPlanCompilationResult, + ActionPlanDraft, +) +from thoth.domain.actor import ActorRef +from thoth.domain.base import DomainModel +from thoth.domain.canonical import canonical_payload, domain_digest, head_set_digest +from thoth.domain.criterion import CriterionCandidate +from thoth.domain.criterion_contract import CriterionContractRecord +from thoth.domain.enums import ( + EntityType, + ModelRole, + OutcomeStatus, + SufficiencyStatus, +) +from thoth.domain.evidence import EvidenceSpan, InformationSufficiencyAssessment +from thoth.domain.evidence_requirements import ( + HypothesisSemanticReview, + HypothesisSemanticReviewRecord, +) +from thoth.domain.hypothesis import HypothesisPortfolio +from thoth.domain.hypothesis_review_validation import review_coverage, target_ids +from thoth.domain.memory import MemoryRecord +from thoth.domain.memory_preparation import ( + MemoryPreparationBasis, + MemoryPreparationStale, + PreparedMemoryPromotion, +) +from thoth.domain.model import ContextPack, ModelRequest, ModelResult +from thoth.domain.outcome import OutcomeRecord +from thoth.domain.relation import DependencyRelation +from thoth.domain.research_execution import ResearchWork, check_research_boundary, research_work +from thoth.domain.research_identity import ResearchIdentityError, decode_research +from thoth.domain.revision import ( + EntitySnapshot, + ImpactPropagationPlan, + RevisionChangeSet, + SemanticRevision, + StagedRevision, +) +from thoth.ports.dependency import DependencyGraphPort +from thoth.ports.ledger import LedgerPort +from thoth.ports.memory import MemoryStorePort +from thoth.ports.model import ModelOutputContractHold, ModelPort +from thoth.ports.runtime import AtomicUnitOfWorkPort, ClockPort, IdGeneratorPort + +ACTION_PLAN_MAX_OUTPUT_TOKENS = 12_000 + + +@dataclass(frozen=True) +class ThreadCycleCommand: + case_id: str + project_id: str + thread_id: str + object_id: str + problem: str + cutoff_at: datetime + criteria: tuple[CriterionCandidate, ...] + evidence: tuple[EvidenceSpan, ...] + sufficiency_signals: SufficiencySignals + action_policy: ActionCompilationPolicy + actor: ActorRef + policy_version: str + model_policy_ref: str + expected_head_overrides: dict[str, str] = field(default_factory=dict) + memory_scope: dict[str, str] = field(default_factory=dict) + + +class ThreadCycleResult(DomainModel): + assessment: InformationSufficiencyAssessment + portfolio: HypothesisPortfolio + action_plan: ActionPlan + action_compilation: ActionPlanCompilationResult + outcome: OutcomeRecord | None = None + commit: CommitResult + memory_ids: tuple[str, ...] + model_ids: tuple[str, ...] + full_memory: FullMemoryPromotionResult | None = None + semantic_repair_attempted: bool = False + + +class ThreadCycleBranchResult(DomainModel): + project_id: str + thread_id: str + terminal_state: Literal["BRANCHED"] = "BRANCHED" + reason_code: Literal["THREAD_CYCLE_HEAD_CONFLICT"] = "THREAD_CYCLE_HEAD_CONFLICT" + commit: CommitResult + downstream_after_branch_executed: Literal[False] = False + prior_acquisition_completed: bool = False + input_disposition: Literal["RETAINED_FOR_EXPLICIT_RETRY"] = "RETAINED_FOR_EXPLICIT_RETRY" + + @model_validator(mode="after") + def require_branch_commit(self) -> ThreadCycleBranchResult: + if self.commit.disposition != CommitDisposition.BRANCH: + raise ValueError("branch result requires a branch commit") + return self + + +class ThreadCycleService: + def __init__( + self, + *, + ledger: LedgerPort, + memory: MemoryStorePort, + model: ModelPort, + commits: RevisionCommitService, + clock: ClockPort, + ids: IdGeneratorPort, + dependencies: DependencyGraphPort, + research: ResearchIdentityService, + unit_of_work: AtomicUnitOfWorkPort | None = None, + full_memory: FullProjectMemoryService | None = None, + ) -> None: + self._ledger = ledger + self._memory = memory + self._model = model + self._commits = commits + self._clock = clock + self._ids = ids + self._dependencies = dependencies + self._research = research + self._unit_of_work = unit_of_work + self._full_memory = full_memory + + async def execute(self, command: ThreadCycleCommand) -> ThreadCycleResult: + work = research_work.get() + check_research_boundary() + heads = dict(self._ledger.read_heads(command.project_id)) + head_digest = domain_digest("WORKING_HEADS", "1.0.0", canonical_payload(heads)) + memory_basis = ( + None + if self._full_memory is None + else self._full_memory.capture_basis( + project_id=command.project_id, + cutoff_at=command.cutoff_at, + scope=command.memory_scope, + actor=command.actor, + ) + ) + research = self._research.context(command.project_id, command.object_id, heads) + criterion_staged = self._stage_criteria(command, heads) + previous_portfolio, previous_action_plan = ( + research.portfolio_view, + research.action_plan_view, + ) + assessment = assess_information_sufficiency( + assessment_id=self._ids.new("assessment"), + assessment_revision_id=self._ids.new("assessment-revision"), + project_id=command.project_id, + target_object_id=command.object_id, + cutoff_at=command.cutoff_at, + decision_question=command.problem, + criteria=command.criteria, + evidence=command.evidence, + signals=command.sufficiency_signals, + policy_version=command.policy_version, + input_head_set_digest=head_digest, + ) + context = self._build_model_context(command, assessment, head_digest, research) + if work is not None: + context = context.model_copy(update={"research_context": work.context}) + hypothesis_result = await self._model.structured( + ModelRequest( + role=ModelRole.HYPOTHESIS_GENERATOR, + project_id=command.project_id, + cutoff_at=command.cutoff_at, + context_pack=context, + **generator_contract(work is not None and work.hypothesis_contract_v3), + model_policy_ref=command.model_policy_ref, + max_output_tokens=4_000, + ) + ) + require_unknown = any( + status + in { + SufficiencyStatus.EVIDENCE_ACQUISITION_REQUIRED, + SufficiencyStatus.EXPERT_INPUT_REQUIRED, + } + for status in assessment.derived_status + ) + portfolio = await accepted_portfolio( + self._model, hypothesis_result.output, context, command, require_unknown + ) + review_id = self._ids.new("hypothesis-semantic-review") if work is not None else None + if work is not None: + # A generator cannot grant epistemic or empirical promotion to itself. + portfolio = draft_portfolio(portfolio, review_id) + if portfolio.generated_from_head_set != head_digest: + raise ValueError("hypothesis portfolio was generated from a different head set") + if ( + previous_portfolio is not None + and portfolio.portfolio_id != previous_portfolio.portfolio_id + ): + raise ValueError("reanalysis must preserve the existing hypothesis portfolio ID") + if self._full_memory is not None and memory_basis is not None: + self._full_memory.require_authority(memory_basis) + hypothesis_batch = ( + None + if work is None + else self._research.stage_hypotheses(portfolio, actor=command.actor, context=research) + ) + hypothesis_review = None + if work is not None: + assert hypothesis_batch is not None + hypothesis_review, context = await self._review_hypotheses( + command, context, portfolio, hypothesis_batch, work + ) + action_result, action_compilation, semantic_repair_attempted = await self._plan_actions( + command, context, portfolio, memory_basis + ) + plan = require_compiled_behavior_plan(action_compilation.plan) + if work is not None: + from thoth.application.services.research_decision_gates import qualify_action_frontier + + plan = qualify_action_frontier(plan, work) + if work is not None and work.show_draft is not None: + work.show_draft( + "ACTION_COMPARISON", + { + "state": "DRAFT_NOT_COMMITTED", + "action_plan": plan.model_dump(mode="json"), + "hypothesis_review": work.context.get("hypothesis_review"), + }, + ) + if plan.plan_revision_digest != head_digest: + raise ValueError("action plan was generated from a different head set") + if previous_action_plan is not None and plan.plan_id != previous_action_plan.plan_id: + raise ValueError("reanalysis must preserve the existing action plan ID") + research_batch = ( + self._research.stage_cycle(portfolio, plan, actor=command.actor, context=research) + if hypothesis_batch is None + else ResearchOwnershipBatch( + hypothesis_batch, + self._research.stage_actions( + plan, + actor=command.actor, + context=research, + hypotheses=hypothesis_batch.hypotheses, + ), + ) + ) + staged_items = [ + self._stage( + command, + EntityType.EVIDENCE, + assessment.assessment_id, + assessment.model_dump(mode="python"), + command.evidence, + heads, + ), + research_batch.hypotheses.staged[0], + research_batch.actions.staged[0], + ] + outcome = None + if previous_portfolio is not None or previous_action_plan is not None: + outcome = self._reanalysis_outcome(command, plan, head_digest) + staged_items.append( + self._stage( + command, + EntityType.OUTCOME, + outcome.outcome_id, + outcome.model_dump(mode="python"), + command.evidence, + heads, + ) + ) + staged_items.extend(criterion_staged) + if hypothesis_review is not None and review_id is not None and work is not None: + assert hypothesis_batch is not None + staged_items.append( + self._stage( + command, + EntityType.EVIDENCE, + review_id, + HypothesisSemanticReviewRecord( + request_ref=work.request_ref, + target_revision_digests=tuple( + h.revision_digest for h in hypothesis_batch.hypotheses + ), + portfolio_revision_digest=hypothesis_batch.portfolio.revision_digest, + review=hypothesis_review, + ).model_dump(mode="python"), + command.evidence, + heads, + ) + ) + staged = (*staged_items, *research_batch.additional_staged) + expected_heads = expected_cycle_heads(heads, staged, command.expected_head_overrides) + commit, memories, full_memory_result = await self._commit_cycle( + command=command, + heads=heads, + expected_heads=expected_heads, + staged=staged, + assessment=assessment, + portfolio=portfolio, + plan=plan, + outcome=outcome, + memory_basis=memory_basis, + research_batch=research_batch, + ) + return ThreadCycleResult( + assessment=assessment, + portfolio=portfolio, + action_plan=plan, + action_compilation=action_compilation, + outcome=outcome, + commit=commit, + memory_ids=tuple(record.memory_id for record in memories), + model_ids=(hypothesis_result.model_id, action_result.model_id), + full_memory=full_memory_result, + semantic_repair_attempted=semantic_repair_attempted, + ) + + def _reanalysis_outcome( + self, command: ThreadCycleCommand, plan: ActionPlan, head_digest: str + ) -> OutcomeRecord: + return OutcomeRecord( + outcome_id=f"outcome:{command.thread_id}", + project_id=command.project_id, + action_id=plan.frontier[0] if plan.frontier else plan.alternatives[0].action_id, + status=OutcomeStatus.OBSERVED, + observed_evidence_refs=tuple(span.span_id for span in command.evidence), + hypothesis_updates=(), + interpretation=( + "Reanalysis was recorded after connected evidence changed; causal attribution " + "has not been independently established." + ), + limitations=("This outcome record does not certify causality or scientific truth.",), + recorded_at=self._clock.now(), + input_head_set_digest=head_digest, + ) + + async def _review_hypotheses( + self, + command: ThreadCycleCommand, + context: ContextPack, + portfolio: HypothesisPortfolio, + hypothesis_batch: HypothesisOwnershipBatch, + work: ResearchWork, + ) -> tuple[HypothesisSemanticReview, ContextPack]: + assert hypothesis_batch is not None + if work.show_draft is not None: + work.show_draft( + "HYPOTHESIS_REVIEW", + { + "state": "DRAFT_NOT_COMMITTED", + "portfolio": portfolio.model_dump(mode="json"), + "proposed_revision_refs": [ + h.revision_digest for h in hypothesis_batch.hypotheses + ], + }, + ) + review_result = await self._model.structured( + ModelRequest( + role=ModelRole.HYPOTHESIS_REVIEWER, + project_id=command.project_id, + cutoff_at=command.cutoff_at, + context_pack=context.model_copy( + update={ + "candidate_portfolio": portfolio, + "research_context": { + **work.context, + "hypothesis_review_target_ids": list(target_ids(portfolio)), + "task": "Review the exact proposed full revisions. " + "Return exactly one decision for every input hypothesis_id; " + "if evidence is insufficient for an ID, mark that ID INCONCLUSIVE. " + "Evaluate entailment, counterevidence, uncertainty " + "and discriminating tests. Zero or one hypothesis is allowed; " + "preserve alternatives considered and next checks. " + "Do not invent 11-axis scores, independent evaluation " + "or human calibration. " + "Keep promotion HOLD where profile requirements are unverified.", + "proposed_full_hypotheses": hypothesis_batch.hypotheses, + "proposed_full_portfolio": hypothesis_batch.portfolio, + }, + } + ), + output_model=HypothesisSemanticReview, + prompt_version="hypothesis_semantic_review.v5", + model_policy_ref=command.model_policy_ref, + max_output_tokens=5000, + ) + ) + from thoth.application.services.research_decision_gates import qualify_hypothesis_review + + hypothesis_review = qualify_hypothesis_review(review_result.output, work) + coverage = review_coverage(portfolio, hypothesis_review.decisions) + if not coverage.ok: + raise ModelOutputContractHold("HYPOTHESIS_REVIEW_COVERAGE_MISMATCH") + if any( + set(d.evidence_refs) - {s.span_id for s in command.evidence} + for d in hypothesis_review.decisions + ): + raise ValueError("HYPOTHESIS_REVIEW_UNKNOWN_EVIDENCE") + work.context["hypothesis_review"] = hypothesis_review.model_dump(mode="json") + work.context["hypothesis_revision_refs"] = [ + h.revision_digest for h in hypothesis_batch.hypotheses + ] + context = context.model_copy(update={"research_context": work.context}) + return hypothesis_review, context + + async def _plan_actions( + self, + command: ThreadCycleCommand, + context: ContextPack, + portfolio: HypothesisPortfolio, + memory_basis: MemoryPreparationBasis | None, + ) -> tuple[ModelResult[ActionPlanDraft], ActionPlanCompilationResult, bool]: + action_result = await self._model.structured( + ModelRequest( + role=ModelRole.ACTION_PLANNER, + project_id=command.project_id, + cutoff_at=command.cutoff_at, + context_pack=context.model_copy(update={"candidate_portfolio": portfolio}), + output_model=ActionPlanDraft, + prompt_version="action_alternatives.v3", + model_policy_ref=command.model_policy_ref, + max_output_tokens=ACTION_PLAN_MAX_OUTPUT_TOKENS, + ) + ) + semantic_repair_attempted = False + action_compilation = compile_action_plan( + action_result.output, + evidence_ids=frozenset(span.span_id for span in command.evidence), + hypothesis_ids=frozenset( + hypothesis.hypothesis_id for hypothesis in portfolio.hypotheses + ), + policy=command.action_policy, + ) + if should_semantic_repair(action_compilation.plan is None): + semantic_repair_attempted = True + repair_context = context.model_copy( + update={ + "policy_hints": { + **context.policy_hints, + "semantic_repair_attempt": 1, + "action_compilation_hold_reasons": action_compilation.hold_reasons, + "allowed_evidence_ids": tuple(span.span_id for span in command.evidence), + "allowed_hypothesis_ids": tuple( + hypothesis.hypothesis_id for hypothesis in portfolio.hypotheses + ), + "repair_contract": ( + "Replace the rejected draft. Preserve no rejected action IDs unless " + "they can be made valid. Use only allowed IDs, at least two materially " + "different action families, and at least one safe R0-R2 frontier item." + ), + } + } + ) + if self._full_memory is not None and memory_basis is not None: + self._full_memory.require_authority(memory_basis) + repair_result = await self._model.structured( + ModelRequest( + role=ModelRole.ACTION_PLANNER, + project_id=command.project_id, + cutoff_at=command.cutoff_at, + context_pack=repair_context.model_copy( + update={"candidate_portfolio": portfolio} + ), + output_model=ActionPlanDraft, + prompt_version="action_alternatives.semantic_repair.v3", + model_policy_ref=command.model_policy_ref, + max_output_tokens=ACTION_PLAN_MAX_OUTPUT_TOKENS, + ) + ) + action_compilation = compile_action_plan( + repair_result.output, + evidence_ids=frozenset(span.span_id for span in command.evidence), + hypothesis_ids=frozenset( + hypothesis.hypothesis_id for hypothesis in portfolio.hypotheses + ), + policy=command.action_policy, + ) + if action_compilation.plan is None: + raise ValueError( + "action plan compilation held after one semantic repair: " + + "; ".join(action_compilation.hold_reasons) + ) + action_result = repair_result + return action_result, action_compilation, semantic_repair_attempted + + async def _commit_cycle( + self, + *, + command: ThreadCycleCommand, + heads: dict[str, str], + expected_heads: dict[str, str], + staged: tuple[StagedRevision, ...], + assessment: InformationSufficiencyAssessment, + portfolio: HypothesisPortfolio, + plan: ActionPlan, + outcome: OutcomeRecord | None, + memory_basis: MemoryPreparationBasis | None, + research_batch: ResearchOwnershipBatch, + ) -> tuple[CommitResult, tuple[MemoryRecord, ...], FullMemoryPromotionResult | None]: + memories = self._prepare_memory(command.project_id, staged) + # With a research attempt on record, compare what it read and wrote, not every head. + scope = read_scope( + research_work.get(), + command.project_id, + heads, + (f"{item.revision.entity_type.value}:{item.revision.entity_id}" for item in staged), + ) + if scope is not None: + expected_heads = {**scope.expected_heads, **expected_heads} + attempt = 1 + relations = MemoryRelationBudget() + while True: + try: + prepared = await prepare_cycle_memory( + self._full_memory, + thread_id=command.thread_id, + basis=memory_basis, + scope=scope, + expected_heads=expected_heads, + candidates=memories, + staged=staged, + attempt=attempt, + relations=relations, + ) + commit, memories, full_memory_result = self._save_cycle( + command=command, + heads=heads, + expected_heads=expected_heads, + scope=scope, + staged=staged, + assessment=assessment, + portfolio=portfolio, + plan=plan, + outcome=outcome, + memory_basis=memory_basis, + research_batch=research_batch, + memories=memories, + prepared=prepared, + ) + break + except MemoryPreparationStale: + # Memory was added after the review (a user correction, another investigation). + # The save was rolled back whole, so one fresh review and save cannot duplicate it. + if attempt >= 2: + raise + attempt += 1 + if commit.disposition == CommitDisposition.FAST_FORWARD: + from thoth.application.services.research_basis_capture import capture_cycle_produced + + capture_cycle_produced(staged, commit.receipt.receipt_id) + return commit, memories, full_memory_result + + def _save_cycle( + self, + *, + command: ThreadCycleCommand, + heads: dict[str, str], + expected_heads: dict[str, str], + scope: ReadScope | None, + staged: tuple[StagedRevision, ...], + assessment: InformationSufficiencyAssessment, + portfolio: HypothesisPortfolio, + plan: ActionPlan, + outcome: OutcomeRecord | None, + memory_basis: MemoryPreparationBasis | None, + research_batch: ResearchOwnershipBatch, + memories: tuple[MemoryRecord, ...], + prepared: PreparedMemoryPromotion | None, + ) -> tuple[CommitResult, tuple[MemoryRecord, ...], FullMemoryPromotionResult | None]: + """One transaction: the ledger commit, the research records and the prepared memory.""" + + full_memory_result: FullMemoryPromotionResult | None = None + with ( + self._ledger.transaction(), + self._unit_of_work.transaction() if self._unit_of_work is not None else nullcontext(), + ): + check_research_boundary() + if self._full_memory is not None and memory_basis is not None: + self._full_memory.require_authority(memory_basis) + commit = self._commits.commit( + RevisionChangeSet( + changeset_id=self._ids.new("changeset"), + project_id=command.project_id, + expected_heads=expected_heads, + expected_absent_heads=() if scope is None else scope.expected_absent, + expected_head_set_digest=None if scope is not None else head_set_digest(heads), + staged_revisions=staged, + impact_plan=ImpactPropagationPlan(), + actor=command.actor, + reason=f"thread cycle {command.thread_id}", + ) + ) + hypothesis_ref = f"HYPOTHESIS:{portfolio.portfolio_id}" + action_ref = f"ACTION:{plan.plan_id}" + outcome_ref = None if outcome is None else f"OUTCOME:{outcome.outcome_id}" + if commit.disposition == CommitDisposition.FAST_FORWARD: + self._research.persist_cycle(research_batch) + self._record_dependencies( + command=command, + assessment=assessment, + hypothesis_ref=hypothesis_ref, + action_ref=action_ref, + outcome_ref=outcome_ref, + staged=staged, + ) + if self._full_memory is None: + for record in memories: + self._memory.add(record) + elif prepared is None: + raise ValueError("MEMORY_PREPARATION_REQUIRED") + else: + full_memory_result = self._full_memory.commit_prepared( + prepared, + expected_head=commit.after_head_set_digest, + ) + else: + memories = () + return commit, memories, full_memory_result + + def _record_dependencies( + self, + *, + command: ThreadCycleCommand, + assessment: InformationSufficiencyAssessment, + hypothesis_ref: str, + action_ref: str, + outcome_ref: str | None, + staged: tuple[StagedRevision, ...], + ) -> None: + self._dependencies.add( + DependencyRelation( + relation_id=self._ids.new("relation"), + project_id=command.project_id, + source_ref=f"EVIDENCE:{assessment.assessment_id}", + relation_type="DERIVES", + target_ref=hypothesis_ref, + revision_digest=staged[1].revision.revision_digest, + ) + ) + self._dependencies.add( + DependencyRelation( + relation_id=self._ids.new("relation"), + project_id=command.project_id, + source_ref=hypothesis_ref, + relation_type="DERIVES", + target_ref=action_ref, + revision_digest=staged[2].revision.revision_digest, + ) + ) + if outcome_ref is not None: + self._dependencies.add( + DependencyRelation( + relation_id=self._ids.new("relation"), + project_id=command.project_id, + source_ref=action_ref, + relation_type="DERIVES", + target_ref=outcome_ref, + revision_digest=staged[3].revision.revision_digest, + ) + ) + work = research_work.get() + if work is not None and work.context.get("answer_status") == "PARTIAL_HOLD": + return + self._dependencies.mark_current( + command.project_id, + tuple( + f"{item.revision.entity_type.value}:{item.revision.entity_id}" for item in staged + ), + caused_by_revision=staged[-1].revision.revision_digest, + updated_at=self._clock.now().isoformat(), + ) + + @staticmethod + def _build_model_context( + command: ThreadCycleCommand, + assessment: InformationSufficiencyAssessment, + head_digest: str, + research: ResearchContext, + ) -> ContextPack: + return ContextPack( + case_id=command.case_id, + project_id=command.project_id, + object_id=command.object_id, + problem=command.problem, + evidence=command.evidence, + criteria=command.criteria, + sufficiency=assessment, + input_head_set_digest=head_digest, + policy_hints={ + "minimum_action_tier_by_family": { + family: tier.value + for family, tier in command.action_policy.minimum_tier_by_family.items() + }, + "approver_role_by_family": command.action_policy.approver_role_by_family, + "unknown_family_tier": command.action_policy.unknown_family_tier.value, + }, + research_context={ + "entity_currentness": { + key: value.model_dump(mode="json") + for key, value in research.currentness.items() + } + }, + previous_portfolio=research.portfolio_view, + previous_action_plan=research.action_plan_view, + canonical_hypotheses=research.hypotheses, + canonical_portfolio=research.portfolio, + canonical_actions=research.actions, + canonical_action_plan=research.action_plan, + canonical_test_assessments=research.test_assessments, + unresolved_research_refs=research.unrepresented_refs, + ) + + def _stage_criteria( + self, command: ThreadCycleCommand, heads: dict[str, str] + ) -> tuple[StagedRevision, ...]: + staged: list[StagedRevision] = [] + for candidate in command.criteria: + if candidate.project_id != command.project_id: + raise ResearchIdentityError("CRITERION_PROJECT_SCOPE_MISMATCH") + head = heads.get(f"CRITERION:{candidate.criterion_id}") + if candidate.source_contract_revision is not None: + if head != candidate.source_contract_revision: + raise ResearchIdentityError("CRITERION_OWNER_REVISION_CHANGED") + revision = self._ledger.read_revision_by_digest( + command.project_id, candidate.source_contract_revision + ) + snapshot = ( + None if revision is None else self._ledger.read_snapshot(revision.snapshot_id) + ) + if revision is None or snapshot is None: + raise ResearchIdentityError("CRITERION_OWNER_MISSING") + record = decode_research(revision, snapshot).record + if ( + not isinstance(record, CriterionContractRecord) + or criterion_projection(record) != candidate + ): + raise ResearchIdentityError("CRITERION_OWNER_VIEW_MISMATCH") + from thoth.application.services.research_basis_capture import capture_consumed + + capture_consumed( + command.project_id, + f"CRITERION:{candidate.criterion_id}", + revision.revision_digest, + ) + continue + if head is not None: + revision = self._ledger.read_revision_by_digest(command.project_id, head) + snapshot = ( + None if revision is None else self._ledger.read_snapshot(revision.snapshot_id) + ) + if revision is None or snapshot is None: + raise ResearchIdentityError("CRITERION_OWNER_MISSING") + if not isinstance(decode_research(revision, snapshot).record, CriterionCandidate): + raise ResearchIdentityError("CRITERION_OWNER_BINDING_REQUIRED") + staged.append( + self._stage( + command, + EntityType.CRITERION, + candidate.criterion_id, + candidate.model_dump(mode="python"), + command.evidence, + heads, + ) + ) + return tuple(staged) + + def _stage( + self, + command: ThreadCycleCommand, + entity_type: EntityType, + entity_id: str, + content: dict[str, object], + evidence: tuple[EvidenceSpan, ...], + heads: dict[str, str], + ) -> StagedRevision: + schema_version = str(content.get("schema_version", "1.0.0")) + content_digest = domain_digest("SNAPSHOT", schema_version, canonical_payload(content)) + snapshot = EntitySnapshot( + snapshot_id=self._ids.new("snapshot"), + project_id=command.project_id, + entity_type=entity_type, + entity_id=entity_id, + schema_version=schema_version, + content=content, + content_digest=content_digest, + ) + aggregate_key = f"{entity_type.value}:{entity_id}" + current = heads.get(aggregate_key) + revision_id = self._ids.new("revision") + created_at = self._clock.now() + revision_payload: dict[str, object] = { + "revision_id": revision_id, + "project_id": command.project_id, + "entity_type": entity_type.value, + "entity_id": entity_id, + "snapshot_id": snapshot.snapshot_id, + "parent_revision_digests": [] if current is None else [current], + "actor": command.actor, + "reason": f"thread cycle {command.thread_id}", + "evidence_refs": [span.span_id for span in evidence], + "affected_refs": [], + "created_at": created_at, + "schema_version": schema_version, + } + revision = SemanticRevision.model_validate( + { + **revision_payload, + "revision_digest": domain_digest( + "SEMANTIC_REVISION", schema_version, canonical_payload(revision_payload) + ), + } + ) + return StagedRevision(snapshot=snapshot, revision=revision) + + def _prepare_memory( + self, + project_id: str, + staged: tuple[StagedRevision, ...], + ) -> tuple[MemoryRecord, ...]: + from thoth.application.services.domain_reference_memory import domain_reference_memory + + return tuple( + record + for item in staged + if ( + record := domain_reference_memory( + item.revision, self._ids.new("memory"), item.snapshot.content + ) + ) + is not None + ) diff --git a/src/thoth/application/workflows/thread_cycle_preparation.py b/src/thoth/application/workflows/thread_cycle_preparation.py new file mode 100644 index 0000000..43c7b86 --- /dev/null +++ b/src/thoth/application/workflows/thread_cycle_preparation.py @@ -0,0 +1,38 @@ +"""Pure cycle preparation over values already captured by ThreadCycleService.execute.""" + +from thoth.domain.enums import HypothesisStatus, PortfolioStatus +from thoth.domain.hypothesis import HypothesisPortfolio +from thoth.domain.revision import StagedRevision + + +def draft_portfolio( + portfolio: HypothesisPortfolio, + review_id: str | None, +) -> HypothesisPortfolio: + return portfolio.model_copy( + update={ + "status": PortfolioStatus.DRAFT, + "hypotheses": tuple( + h.model_copy( + update={ + "status": HypothesisStatus.DRAFT, + "semantic_review_ref": review_id, + } + ) + for h in portfolio.hypotheses + ), + } + ) + + +def expected_cycle_heads( + heads: dict[str, str], + staged: tuple[StagedRevision, ...], + expected_head_overrides: dict[str, str] | None, +) -> dict[str, str]: + staged_keys = { + f"{item.revision.entity_type.value}:{item.revision.entity_id}" for item in staged + } + expected_heads = {key: value for key, value in heads.items() if key in staged_keys} + expected_heads.update(expected_head_overrides or {}) + return expected_heads diff --git a/src/thoth/domain/enums.py b/src/thoth/domain/enums.py index cffb01b..f01256b 100644 --- a/src/thoth/domain/enums.py +++ b/src/thoth/domain/enums.py @@ -85,6 +85,7 @@ class ModelRole(StrEnum): EVIDENCE_EXTRACTOR = "EVIDENCE_EXTRACTOR" SUFFICIENCY_EXPLAINER = "SUFFICIENCY_EXPLAINER" HYPOTHESIS_GENERATOR = "HYPOTHESIS_GENERATOR" + HYPOTHESIS_TABLE_FILLER = "HYPOTHESIS_TABLE_FILLER" COUNTEREVIDENCE_CHALLENGER = "COUNTEREVIDENCE_CHALLENGER" ACTION_PLANNER = "ACTION_PLANNER" USER_EXPLAINER = "USER_EXPLAINER" diff --git a/src/thoth/domain/hypothesis.py b/src/thoth/domain/hypothesis.py index 24e4244..4ba3585 100644 --- a/src/thoth/domain/hypothesis.py +++ b/src/thoth/domain/hypothesis.py @@ -145,6 +145,75 @@ class HypothesisPortfolioV3(HypothesisPortfolio): """The v3 output form: a v2 portfolio whose hypotheses may add expected results.""" hypotheses: tuple[SerializeAsAny[HypothesisV3], ...] # pyright: ignore[reportIncompatibleVariableOverride] + # What the table-filling call did (see hypothesis_table_filler); never part of the form the + # model fills in. + _table_fill: Any = PrivateAttr(default=None) + + @property + def table_fill(self) -> TableFillRecord | None: + return self._table_fill + + def note_table_fill(self, record: TableFillRecord) -> None: + self._table_fill = record + + +class FilledRow(DomainModel): + """One cell the table filler answers. The limits are looser than ExpectedResult's: the server + settles what is too long or unknown instead of refusing the whole answer.""" + + hypothesis_id: HypothesisId + expected: str = Field(min_length=1, max_length=300) + basis: tuple[EvidenceSpanId, ...] = Field(default=(), max_length=20) + # Only for 모름: one sentence saying under which condition this test's result is left + # undetermined even if the hypothesis were true. Kept in the filling record, not in the table. + unknown_reason: str | None = Field(default=None, max_length=2000) + + +class FilledTest(DomainModel): + """The cells the filler answers for one test (named by the hypothesis it was designed for).""" + + designed_for: HypothesisId + test_id: str = Field(min_length=1, max_length=300) + rows: tuple[FilledRow, ...] = Field(default=(), max_length=40) + + +class HypothesisTableFill(DomainModel): + """The output of the table-filling call: cells only, never a new test or hypothesis.""" + + tests: tuple[FilledTest, ...] = Field(default=(), max_length=60) + + +TableFillState = Literal["CALLED", "SKIPPED_FULL", "SKIPPED_BUDGET", "FAILED"] +TableFillOutcome = Literal["CHANGED", "STILL_UNKNOWN", "NOT_ANSWERED", "DROPPED"] + + +class TableFillAnswer(DomainModel): + """What the filler said for one cell it was asked about (cells it was not asked are absent). + + CHANGED: a value replaced a missing or 모름 cell. STILL_UNKNOWN: it answered 모름 (with its + reason). NOT_ANSWERED: it gave no row for the cell. DROPPED: its answer was not kept. + """ + + test_id: str = Field(max_length=300) + hypothesis_id: HypothesisId + expected: str = Field(default="", max_length=80) + unknown_reason: str | None = Field(default=None, max_length=200) + outcome: TableFillOutcome + + +class TableFillRecord(DomainModel): + """Whether the table-filling call was made, and what it changed (kept beside the portfolio). + + A cell is a (test, hypothesis) pair; it is open when its row is missing or says 모름. + """ + + state: TableFillState + reason_code: str | None = Field(default=None, max_length=200) + cells: int = Field(default=0, ge=0) + open_before: int = Field(default=0, ge=0) + open_after: int = Field(default=0, ge=0) + changed_cells: int = Field(default=0, ge=0) + answers: tuple[TableFillAnswer, ...] = () def is_v3(portfolio: HypothesisPortfolio) -> bool: diff --git a/src/thoth/domain/model_call_settings.py b/src/thoth/domain/model_call_settings.py index 2a20b13..fff1231 100644 --- a/src/thoth/domain/model_call_settings.py +++ b/src/thoth/domain/model_call_settings.py @@ -6,11 +6,10 @@ from thoth.domain.base import DomainModel -# The one place the default lives. Off: a cut-off call is shown to the user, who decides (the -# result card offers "이어서 조사"). The server cannot tell whether it already received a request -# (no idempotency) or whether the cut-off one stopped, so a send-again can repeat the same work -# or a runaway; a project turns it on knowingly. When on: at most twice, 2 s then 4 s. -AUTO_RETRY_INTERRUPTED_DEFAULT = False +# 사용자 결정 20261010-01: on for projects without a saved choice; saved False stays off. +# A retry can repeat remote work (no remote stop/idempotency proof). The existing limit remains +# at most twice, waiting about 2 s then 4 s; projects can turn it off and resume manually. +AUTO_RETRY_INTERRUPTED_DEFAULT = True # Hypothesis generation contract v3 (what each hypothesis expects from a test, and what would refute # it): off, so a project keeps the v2 contract exactly until it turns this on. HYPOTHESIS_CONTRACT_V3_DEFAULT = False diff --git a/src/thoth/domain/model_dispatch.py b/src/thoth/domain/model_dispatch.py index 5c63d0c..3f52cc9 100644 --- a/src/thoth/domain/model_dispatch.py +++ b/src/thoth/domain/model_dispatch.py @@ -102,6 +102,15 @@ class TransportDiagnostic(DomainModel): httpx_error_type: str | None = Field( default=None, min_length=1, max_length=64, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$" ) + error_message_prefix: str | None = Field( + default=None, min_length=1, max_length=256, pattern=r"^[\x20-\x7e]+$" + ) + x_oai_request_id: str | None = Field( + default=None, min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$" + ) + cf_ray: str | None = Field( + default=None, max_length=20, pattern=r"^[0-9a-fA-F]{16}(-[A-Z]{3})?$" + ) nested_cause_category: Literal[ "NONE", "OS_ERROR", diff --git a/src/thoth/domain/research_execution.py b/src/thoth/domain/research_execution.py index 040afa3..6314a62 100644 --- a/src/thoth/domain/research_execution.py +++ b/src/thoth/domain/research_execution.py @@ -63,6 +63,9 @@ class ResearchWork: boundary: ResearchBoundary evidence: tuple[EvidenceSpan, ...] = () context: dict[str, object] = field(default_factory=dict) + # What the published result carries but no model call is shown: the context above is sent to + # the later model calls, so a record about one call's answers must not be put in it. + result_only: dict[str, object] = field(default_factory=dict) record_refs: list[RevisionRef] = field(default_factory=list) pending_revisions: list[StagedRevision] = field(default_factory=list) prepare: Callable[[], Awaitable[None]] | None = None diff --git a/src/thoth/domain/research_projection.py b/src/thoth/domain/research_projection.py index 46de0f8..f95afb0 100644 --- a/src/thoth/domain/research_projection.py +++ b/src/thoth/domain/research_projection.py @@ -16,7 +16,7 @@ PortfolioStatus, Reversibility, ) -from thoth.domain.hypothesis import DiscriminatingTest, ExpectedResult +from thoth.domain.hypothesis import DiscriminatingTest, ExpectedResult, TableFillRecord from thoth.domain.r2_loop import HypothesisExecutionAppraisal, R2ExecutionSummary from thoth.domain.recovery import RecoveryCandidateRevision from thoth.domain.test_validity import PredictionProposal @@ -30,11 +30,16 @@ class ExpectedByTest(DomainModel): class DroppedExpectation(DomainModel): - """An expected-result item that was not kept, and why; the rest of the output stands.""" + """An item of the generator's output that was not kept, and why; the rest of the output stands. + + An expected-result row names its test (`test_id`); a refutation condition has no test, so its + `test_id` is empty and its original `text` is kept so the drop can be audited. + """ test_id: str hypothesis_id: str reason: str + text: str | None = None class HypothesisGenerationDetails(DomainModel): @@ -65,6 +70,8 @@ class PortfolioGenerationDetails(DomainModel): alternatives_considered: tuple[str, ...] = () next_checks: tuple[str, ...] = () uncertainty_reserve: str = "UNASSESSED" + # Contract v3 only: whether the table-filling call was made and what it changed. + table_fill: TableFillRecord | None = None class ActionGenerationDetails(DomainModel): diff --git a/src/thoth/domain/trace_origin.py b/src/thoth/domain/trace_origin.py index 2bb1c2e..1ccfe98 100644 --- a/src/thoth/domain/trace_origin.py +++ b/src/thoth/domain/trace_origin.py @@ -15,6 +15,12 @@ ORIGIN_SCHEMA_VERSION = "1.0.0" MAX_ORIGIN_REFS = 20 +# Other conditions of the same measure under the same requirement, shown beside the row so a cause +# can be looked for in what differs. Few, with a few source positions each, to keep the context +# small. +MAX_SIBLING_VERDICTS = 4 +MAX_SIBLING_SPAN_REFS = 3 +MAX_COMPARISON_REFS = 8 class TraceOriginInput(DomainModel): @@ -27,6 +33,21 @@ class TraceOriginInput(DomainModel): verdict_revision: str = Field(min_length=64, max_length=64) +class TraceSiblingVerdict(DomainModel): + """The verdict of another condition of the same measure, as the stored trace has it.""" + + criterion_id: str = Field(min_length=1, max_length=500) + condition: str | None = Field(default=None, max_length=500) + state: str = Field(min_length=1, max_length=100) + rule_summary: str | None = Field(default=None, max_length=500) + # The chosen result the verdict was computed from (none while no result is chosen). + result_id: str | None = Field(default=None, max_length=500) + value: str | None = Field(default=None, max_length=100) + numerator: int | None = None + denominator: int | None = None + unit: str | None = Field(default=None, max_length=100) + + class TraceVerdictOrigin(DomainModel): """The row as the stored trace has it, written by the server.""" @@ -46,3 +67,8 @@ class TraceVerdictOrigin(DomainModel): rule_summary: str | None = Field(default=None, max_length=500) chosen_result_ids: tuple[str, ...] = Field(default=(), max_length=MAX_ORIGIN_REFS) source_span_refs: tuple[str, ...] = Field(default=(), max_length=MAX_ORIGIN_REFS) + sibling_verdicts: tuple[TraceSiblingVerdict, ...] = Field( + default=(), max_length=MAX_SIBLING_VERDICTS + ) + # Where the siblings' own results are written; pinned beside source_span_refs. + comparison_span_refs: tuple[str, ...] = Field(default=(), max_length=MAX_COMPARISON_REFS) diff --git a/tests/architecture/public_source_profile.py b/tests/architecture/public_source_profile.py index b6cf643..851807e 100644 --- a/tests/architecture/public_source_profile.py +++ b/tests/architecture/public_source_profile.py @@ -11,6 +11,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +from scripts.update_source_manifest import read_source_bytes _ANCHORS = ( "docs/architecture/rpc-method-catalog.md", @@ -120,6 +121,12 @@ class _ArchitectureOwners(BaseModel): canonical_owners: tuple[_CanonicalOwner, ...] +def _check_file_bytes(row: _FileRow, data: bytes) -> bytes: + if len(data) != row.bytes or hashlib.sha256(data).hexdigest() != row.sha256: + raise PublicPackageError(f"listed public file bytes differ: {row.path}{_STALE_LIST_HINT}") + return data + + @dataclass(frozen=True, slots=True) class PublicSourceProfile: root: Path @@ -130,16 +137,11 @@ def read_bytes(self, relative: str) -> bytes: row = self.files.get(relative) if row is None: raise PublicPackageError(f"required public anchor is not listed: {relative}") - path = self.root / relative try: - data = path.read_bytes() - except OSError as exc: + data = read_source_bytes(self.root, [relative])[relative] + except (OSError, subprocess.CalledProcessError, ValueError) as exc: raise PublicPackageError(f"listed public file is unreadable: {relative}") from exc - if len(data) != row.bytes or hashlib.sha256(data).hexdigest() != row.sha256: - raise PublicPackageError( - f"listed public file bytes differ: {relative}{_STALE_LIST_HINT}" - ) - return data + return _check_file_bytes(row, data) def read_text(self, relative: str) -> str: return self.read_bytes(relative).decode("utf-8") @@ -174,7 +176,12 @@ def public_source_profile(root: Path) -> PublicSourceProfile | None: raise PublicPackageError( f"listed public file is missing: {row.path}{_STALE_LIST_HINT}" ) from exc - profile.read_bytes(row.path) + try: + contents = read_source_bytes(root, list(files)) + except (OSError, subprocess.CalledProcessError, ValueError) as exc: + raise PublicPackageError("listed public files are unreadable") from exc + for row in manifest.files: + _check_file_bytes(row, contents[row.path]) for relative in _ANCHORS: profile.read_bytes(relative) for relative in _EXCLUDED_FILES: diff --git a/tests/architecture/test_public_source_profile.py b/tests/architecture/test_public_source_profile.py index 72ce0fa..a7eb603 100644 --- a/tests/architecture/test_public_source_profile.py +++ b/tests/architecture/test_public_source_profile.py @@ -56,7 +56,7 @@ def _public_fixture(root: Path) -> dict[str, object]: for relative, text in sorted(_ANCHOR_TEXT.items()): path = root / relative path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") + path.write_text(text, encoding="utf-8", newline="\n") raw = path.read_bytes() rows.append( {"path": relative, "sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw)} diff --git a/tests/integration/test_acquisition_io_transaction_boundary.py b/tests/integration/test_acquisition_io_transaction_boundary.py new file mode 100644 index 0000000..11ba936 --- /dev/null +++ b/tests/integration/test_acquisition_io_transaction_boundary.py @@ -0,0 +1,80 @@ +"""The normal acquisition path reaches connector awaits outside the storage transaction.""" + +from contextvars import copy_context +from pathlib import Path + +from pytest import MonkeyPatch +from tests.integration.test_a02_autonomous_acquisition import ( + A02Connector, + prepare_thread, + request, + value, +) + +from thoth.domain.connectors import ( + ConnectorAccessRequest, + ConnectorArtifactRef, + ConnectorCheckpoint, + ConnectorFetchResult, +) + + +async def test_connector_discover_and_fetch_have_no_ambient_storage_transaction( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + calls: list[tuple[str, str]] = [] + original_discover, original_fetch = A02Connector.discover, A02Connector.fetch + + def outside_transaction() -> None: + assert not any( + item is not None + for key, item in copy_context().items() + if key.name == "thoth_sqlite_ambient_transaction" + ) + + async def discover( + self: A02Connector, + command: ConnectorAccessRequest, + checkpoint: ConnectorCheckpoint | None = None, + ) -> tuple[ConnectorArtifactRef, ...]: + outside_transaction() + calls.append(("discover", str(command.selector["relative_path"]))) + result = await original_discover(self, command, checkpoint) + outside_transaction() + return result + + async def fetch( + self: A02Connector, + command: ConnectorAccessRequest, + ref: ConnectorArtifactRef, + checkpoint: ConnectorCheckpoint | None = None, + ) -> ConnectorFetchResult: + outside_transaction() + calls.append(("fetch", str(command.selector["relative_path"]))) + result = await original_fetch(self, command, ref, checkpoint) + outside_transaction() + return result + + monkeypatch.setattr(A02Connector, "discover", discover) + monkeypatch.setattr(A02Connector, "fetch", fetch) + runtime, _, project_id = await prepare_thread(tmp_path, allow_connector=True) + try: + analyzed = value( + await runtime.bus.dispatch( + request( + "thread/input", + "acquisition-io-boundary", + {"project_id": project_id, "thread_id": f"thread:{project_id}"}, + ) + ) + ) + assert "autonomous_acquisition" in analyzed + assert calls == [ + ("discover", "initial.md"), + ("fetch", "initial.md"), + ("discover", "catalog.md"), + ("fetch", "catalog.md"), + ] + finally: + runtime.close() diff --git a/tests/integration/test_evidence_coverage.py b/tests/integration/test_evidence_coverage.py new file mode 100644 index 0000000..d32e186 --- /dev/null +++ b/tests/integration/test_evidence_coverage.py @@ -0,0 +1,220 @@ +"""The evidence a trace-row investigation gets from the connected demo files. + +A real runtime with a controlled model (no live model): the demo files are connected the way the +file panel does, the trace is imported with sentence ids from the connected files, and a request +that starts from a trace row is run. What each model stage was given is read from the calls. +""" + +from __future__ import annotations + +import importlib.util +import shutil +import sys +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel +from tests.integration.test_research_request_v2 import ControlledResearchModel, setup +from tests.integration.test_trace_origin import Rpc + +from thoth.application.services.research_retrieval_policy import retrieval_policy +from thoth.application.services.trace_csv import export_csv +from thoth.apps.runtime import AppRuntime +from thoth.domain.canonical import model_digest +from thoth.domain.enums import ModelRole +from thoth.domain.evidence_requirements import EvidenceRanking +from thoth.domain.model import ModelRequest, ModelResult + +REPO = Path(__file__).parents[2] +DEMO = REPO / "examples" / "synthetic-radar-demo-v1" +RAIN = "SYN-C-DET-RAIN" + + +def question_for(row: str) -> str: + """The question the demo preparation helper asks for a trace row.""" + return f'「{row}」 기준의 판정이 "미달"입니다. 원인 후보와 그것을 가를 시험을 찾아 주세요.' + + +def _load_prepare() -> Any: + spec = importlib.util.spec_from_file_location( + "prepare_synthetic_radar_demo", REPO / "scripts" / "prepare_synthetic_radar_demo.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +prepare = _load_prepare() + + +class RerankerPicksNothing(ControlledResearchModel): + """The relevance stage returns no ids: only what the server keeps on its own survives.""" + + async def structured[T: BaseModel](self, request: ModelRequest[T]) -> ModelResult[T]: + if request.role != ModelRole.EVIDENCE_RERANKER: + return await super().structured(request) + self.calls.append(request) # type: ignore[arg-type] + parsed = request.output_model.model_validate( + EvidenceRanking(ordered_span_ids=(), rationale="none").model_dump() + ) + return ModelResult( + output=parsed, + model_id="CONTROLLED_RESEARCH", + scripted=True, + prompt_version=request.prompt_version, + input_digest=model_digest("INPUT", request.context_pack, schema_version="1.0.0"), + output_digest=model_digest("OUTPUT", parsed, schema_version="1.0.0"), + ) + + +async def connect(rpc: Rpc, runtime: AppRuntime, tmp_path: Path, path: Path) -> str: + inbox = tmp_path / "inbox" + inbox.mkdir(exist_ok=True) + shutil.copy(path, inbox / path.name) + media = "text/csv" if path.suffix == ".csv" else "text/plain" + connected = await rpc( + "project/source/connect", + project_id="p", + relative_path=path.name, + media_type=media, + authority="UNCLASSIFIED", + cutoff_state="ELIGIBLE", + security_class="INTERNAL", + ) + return str(connected["artifact"]["artifact_id"]) + + +async def sentence_ids(rpc: Rpc, artifact_id: str) -> dict[int, str]: + listed = await rpc("evidence/list", project_id="p") + return { + int(span["locator"]["line"]): str(span["span_id"]) + for span in listed["evidence"] + if span["artifact_id"] == artifact_id and span["locator"].get("line") is not None + } + + +async def opened_demo( + tmp_path: Path, model: ControlledResearchModel, *, phase: int = 2, with_csv: bool = False +) -> tuple[AppRuntime, Rpc]: + """A project with the demo files connected and the demo trace imported (phase 1 or 2).""" + runtime = await setup(tmp_path, model, source=False) + rpc = Rpc(runtime) + await connect(rpc, runtime, tmp_path, DEMO / prepare.REQUIREMENT_FILE) + await connect(rpc, runtime, tmp_path, DEMO / prepare.TEST_PLAN_FILE) + spans: dict[str, dict[str, str]] = {} + for path in prepare.result_files(demo=DEMO): + if prepare._yaml(path)["available_from_phase"] > phase: # pyright: ignore[reportPrivateUsage] + continue + artifact = await connect(rpc, runtime, tmp_path, path) + found = await sentence_ids(rpc, artifact) + lines = prepare.value_lines(path.read_text(encoding="utf-8")) + spans[path.name] = {part: found[lines[part]] for part in prepare.RESULT_PARTS} + if with_csv: + reference = prepare._yaml(path)["trials_csv"] # pyright: ignore[reportPrivateUsage] + await connect(rpc, runtime, tmp_path, DEMO / reference) + await rpc.load(export_csv(prepare.build_trace_set(DEMO, phase, spans)), "CREATE") + return runtime, rpc + + +async def start_from_row(rpc: Rpc, runtime: AppRuntime, subject_id: str) -> None: + view = await rpc("trace/read", project_id="p") + verdict = next(item for item in view["verdicts"] if item["subject_id"] == subject_id) + origin = { + "kind": "TRACE_VERDICT", + "project_id": "p", + "subject_kind": "CRITERION", + "subject_id": subject_id, + "verdict_revision": verdict["revision_digest"], + } + await rpc( + "thread/start", + project_id="p", + problem=question_for(subject_id), + contract_version=2, + origin=origin, + ) + await runtime.bus.drain() + + +def given(model: ControlledResearchModel, role: ModelRole) -> tuple[Any, ...]: + """The evidence the first call of a role was given.""" + call = next(c for c in model.calls if c.role == role) + return tuple(call.context_pack.evidence) + + +def texts(spans: tuple[Any, ...]) -> set[str]: + return {span.exact_text for span in spans} + + +@pytest.mark.asyncio +async def test_a_pinned_span_survives_both_selection_stages_when_the_ranker_picks_nothing( + tmp_path: Path, +) -> None: + model = RerankerPicksNothing() + runtime, rpc = await opened_demo(tmp_path, model) + try: + view = await rpc("trace/read", project_id="p") + pinned = next(r for r in view["results"] if r["criterion_id"] == RAIN)["source_span_refs"] + await start_from_row(rpc, runtime, RAIN) + planner = given(model, ModelRole.RESEARCH_PLANNER) + ranker = given(model, ModelRole.EVIDENCE_RERANKER) + reviewer = given(model, ModelRole.SEMANTIC_REVIEWER) + for name, stage in (("planner", planner), ("ranker", ranker), ("reviewer", reviewer)): + assert set(pinned) <= {span.span_id for span in stage}, name + assert 'value: "0.80"' in texts(reviewer) + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_the_dry_result_and_its_summary_reach_the_model_for_a_rain_row( + tmp_path: Path, +) -> None: + model = ControlledResearchModel() + runtime, rpc = await opened_demo(tmp_path, model, with_csv=True) + try: + await start_from_row(rpc, runtime, RAIN) + final = texts(given(model, ModelRole.SEMANTIC_REVIEWER)) + # the other condition's counts are in what the reviewer reads, beside the row's own + assert {"numerator: 19", "denominator: 20", 'value: "0.95"'} <= final + assert {"numerator: 16", "denominator: 20", 'value: "0.80"'} <= final + origin = next( + c.context_pack.research_context["origin"] + for c in model.calls + if "origin" in c.context_pack.research_context + ) + dry = next(s for s in origin["sibling_verdicts"] if s["criterion_id"] == "SYN-C-DET-DRY") + assert (dry["state"], dry["value"], dry["numerator"], dry["denominator"]) == ( + "PASS_COMPUTED", + "0.95", + 19, + 20, + ) + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_the_trial_files_are_connected_by_row_and_the_final_evidence_stays_in_budget( + tmp_path: Path, +) -> None: + model = ControlledResearchModel() + runtime, rpc = await opened_demo(tmp_path, model, with_csv=True) + try: + listed = (await rpc("evidence/list", project_id="p"))["evidence"] + rows = [s for s in listed if s["extraction_method"].startswith("csv:")] + assert {s["extraction_method"] for s in rows} == {"csv:1.1.0"} + pointers = [s["locator"]["json_pointer"] for s in rows] + assert pointers.count("/header") == 2 # one header node for each trial file + assert all(p == "/header" or p.startswith("/rows/") for p in pointers) + assert any("row_id=SYN-T-RAIN-03" in s["exact_text"] for s in rows) + await start_from_row(rpc, runtime, RAIN) + final = given(model, ModelRole.SEMANTIC_REVIEWER) + policy = retrieval_policy() + assert len(final) <= policy.max_spans + assert sum(len(s.exact_text) for s in final) <= policy.character_budget + finally: + runtime.close() diff --git a/tests/integration/test_hypothesis_contract_v3_run.py b/tests/integration/test_hypothesis_contract_v3_run.py index a37ccb1..85453ae 100644 --- a/tests/integration/test_hypothesis_contract_v3_run.py +++ b/tests/integration/test_hypothesis_contract_v3_run.py @@ -19,22 +19,62 @@ from tests.integration.test_trace_origin import RAIN, Rpc, opened from thoth.application.services.hypothesis_projection import hypothesis_view +from thoth.domain.canonical import model_digest from thoth.domain.enums import ModelRole -from thoth.domain.hypothesis import HypothesisPortfolioV3, HypothesisV3 +from thoth.domain.hypothesis import ( + FilledRow, + FilledTest, + HypothesisPortfolioV3, + HypothesisTableFill, + HypothesisV3, +) from thoth.domain.hypothesis_full import HypothesisRecord +from thoth.domain.model import ModelResult +from thoth.ports.model import ModelExecutionHold CONTRACT_V2 = "hypothesis_portfolio.v2" -CONTRACT_V3 = "hypothesis_portfolio.v3" +CONTRACT_V3 = "hypothesis_portfolio.v3.3" SAID = "if both runs match, drop it" class GeneratorModel(ControlledResearchModel): """The controlled model; when it is shown the v3 form it fills it in, with some bad ids.""" - def __init__(self) -> None: + def __init__(self, *, filler_error: BaseException | None = None) -> None: super().__init__(one=True, tested=True) + self.filler_error = filler_error + + async def fill(self, request: Any) -> Any: + """The table-filling call: it answers every open cell it is asked for with "same".""" + self.calls.append(request) + if self.filler_error is not None: + raise self.filler_error + asked = request.context_pack.research_context["table_filler"]["tests"] + answer = HypothesisTableFill( + tests=tuple( + FilledTest( + designed_for=item["designed_for"], + test_id=item["test_id"], + rows=tuple( + FilledRow(hypothesis_id=h, expected="same") + for h in item["open_hypothesis_ids"] + ), + ) + for item in asked + ) + ) + return ModelResult( + output=answer, + model_id="CONTROLLED_RESEARCH", + scripted=True, + prompt_version=request.prompt_version, + input_digest=model_digest("INPUT", request.context_pack, schema_version="1.0.0"), + output_digest=model_digest("OUTPUT", answer, schema_version="1.0.0"), + ) async def structured(self, request: Any) -> Any: + if request.role == ModelRole.HYPOTHESIS_TABLE_FILLER: + return await self.fill(request) result = await super().structured(request) if request.role != ModelRole.HYPOTHESIS_GENERATOR: return result @@ -98,8 +138,10 @@ def generator_calls(model: ControlledResearchModel) -> list[Any]: return [call for call in model.calls if call.role == ModelRole.HYPOTHESIS_GENERATOR] -async def run(tmp_path: Path, *, on: bool) -> tuple[Any, Any, Rpc, str, GeneratorModel]: - model = GeneratorModel() +async def run( + tmp_path: Path, *, on: bool, model: GeneratorModel | None = None +) -> tuple[Any, Any, Rpc, str, GeneratorModel]: + model = model or GeneratorModel() runtime, rpc = await opened(tmp_path, model) if on: await turn_on(rpc) @@ -117,24 +159,27 @@ async def test_the_switch_is_off_by_default_and_each_switch_keeps_the_other(tmp_ runtime, rpc = await opened(tmp_path, GeneratorModel()) try: first = await settings(rpc) + # 사용자 결정 20261010-01: only retry defaults on; v3 still defaults off. assert (first["hypothesis_contract_v3"], first["auto_retry_interrupted_model_call"]) == ( False, - False, + True, ) await turn_on(rpc) second = await settings(rpc) assert second["hypothesis_contract_v3"] is True - assert second["auto_retry_interrupted_model_call"] is False # left out: kept + # 사용자 결정 20261010-01: the unchanged retry value is now True. + assert second["auto_retry_interrupted_model_call"] is True # left out: kept + # 사용자 결정 20261010-01: actually change retry to False and retain v3=True. await rpc( "model/callSettings/update", project_id="p", - auto_retry_interrupted_model_call=True, + auto_retry_interrupted_model_call=False, expected_digest=second["settings_digest"], ) third = await settings(rpc) assert (third["hypothesis_contract_v3"], third["auto_retry_interrupted_model_call"]) == ( True, - True, + False, ) finally: runtime.close() @@ -180,7 +225,7 @@ async def test_on_the_generator_sees_v3_and_what_it_says_is_stored_and_shown_wit (first["hypothesis_id"], "worse"), (second["hypothesis_id"], "same"), ] # the two bad items are gone, the rest of the table stands - assert [r["expected"] for r in tests["test-2"]] == ["worse", "모름"] + assert [r["expected"] for r in tests["test-2"]] == ["worse", "same"] # 모름, filled read = (await rpc("hypothesis/read", project_id="p", hypothesis_id=first["hypothesis_id"]))[ "hypothesis" ] @@ -211,8 +256,13 @@ async def test_v3_makes_the_same_number_of_model_calls_and_the_value_it_ran_with off_runtime, off_model, _, _, _ = await run(tmp_path / "off", on=False) on_runtime, on_model, on_rpc, thread_id, _ = await run(tmp_path / "on", on=True) try: - assert len(on_model.calls) == len(off_model.calls) - assert [c.role for c in on_model.calls] == [c.role for c in off_model.calls] + # v3 adds one call, the table-filling call, right after the generator; v2 adds none + roles = [c.role for c in on_model.calls] + assert ModelRole.HYPOTHESIS_TABLE_FILLER not in [c.role for c in off_model.calls] + assert roles.count(ModelRole.HYPOTHESIS_TABLE_FILLER) == 1 + after = roles.index(ModelRole.HYPOTHESIS_GENERATOR) + 1 + assert roles[after] == ModelRole.HYPOTHESIS_TABLE_FILLER + assert [*roles[:after], *roles[after + 1 :]] == [c.role for c in off_model.calls] # the project switches it off afterwards: what was made stays what it was made with current = await settings(on_rpc) await on_rpc( @@ -233,3 +283,114 @@ async def test_v3_makes_the_same_number_of_model_calls_and_the_value_it_ran_with finally: off_runtime.close() on_runtime.close() + + +@pytest.mark.asyncio +async def test_the_filler_fills_only_open_cells_and_what_it_did_is_stored_and_shown( + tmp_path: Path, +) -> None: + runtime, model, rpc, thread_id, _ = await run(tmp_path, on=True) + try: + first, second = (await portfolio_of(rpc, thread_id))["hypotheses"] + tests = {t["test_id"]: t["expected_by_hypothesis"] for t in first["discriminating_tests"]} + # what the generator wrote stays; its 모름 is the one cell of this hypothesis that changed + assert [(r["hypothesis_id"], r["expected"]) for r in tests["test-1"]] == [ + (first["hypothesis_id"], "worse"), + (second["hypothesis_id"], "same"), + ] + assert [(r["hypothesis_id"], r["expected"]) for r in tests["test-2"]] == [ + (first["hypothesis_id"], "worse"), + (second["hypothesis_id"], "same"), + ] + # the second hypothesis wrote no table at all: the first hypothesis's cell is filled in + # its tests, and its own designed-for cell is left as the generator wrote it (missing) + for test in second["discriminating_tests"]: + assert [r["hypothesis_id"] for r in test["expected_by_hypothesis"]] == [ + first["hypothesis_id"] + ] + (call,) = [c for c in model.calls if c.role == ModelRole.HYPOTHESIS_TABLE_FILLER] + assert call.prompt_version == "hypothesis_table_filler.v4" + assert call.max_output_tokens < 4_000 + expected = {"state": "CALLED", "reason_code": None, "cells": 8} + expected |= {"open_before": 5, "open_after": 2, "changed_cells": 3} + record = ( + await rpc( + "hypothesis/portfolio/read", + project_id="p", + portfolio_id=thread_portfolio(await portfolio_of(rpc, thread_id)), + ) + )["portfolio"]["generation_details"]["table_fill"] + answers = record.pop("answers") + assert record == expected + # the three cells it was asked about and filled, named by test and hypothesis + assert [(a["outcome"], a["expected"], a["unknown_reason"]) for a in answers] == [ + ("CHANGED", "same", None) + ] * 3 + read = await rpc("thread/read", project_id="p", thread_id=thread_id, view="FULL") + shown = read["current_result"]["result"]["hypothesis_table_fill"] + assert {k: v for k, v in shown.items() if k != "answers"} == expected + assert len(shown["answers"]) == 3 + finally: + runtime.close() + + +def thread_portfolio(portfolio: dict[str, Any]) -> str: + return str(portfolio["portfolio_id"]) + + +@pytest.mark.asyncio +async def test_a_failed_filler_call_leaves_the_generators_table_and_the_research_completes( + tmp_path: Path, +) -> None: + runtime, model, rpc, thread_id, _ = await run( + tmp_path, on=True, model=GeneratorModel(filler_error=ModelExecutionHold("FILLER_DOWN")) + ) + try: + read = await rpc("thread/read", project_id="p", thread_id=thread_id, view="FULL") + assert read["current_result"]["terminal_reason"] == "BOUNDED_RESEARCH_COMPLETE" + first, second = (await portfolio_of(rpc, thread_id))["hypotheses"] + tests = {t["test_id"]: t["expected_by_hypothesis"] for t in first["discriminating_tests"]} + assert [r["expected"] for r in tests["test-2"]] == ["worse", "모름"] # as written + assert all(t["expected_by_hypothesis"] == [] for t in second["discriminating_tests"]) + record = read["current_result"]["result"]["hypothesis_table_fill"] + assert (record["state"], record["reason_code"], record["changed_cells"]) == ( + "FAILED", + "FILLER_DOWN", + 0, + ) + assert (record["open_before"], record["open_after"]) == (5, 5) + # the rest of the research went on: the reviewer and the planner were still called + roles = {c.role for c in model.calls} + assert {ModelRole.HYPOTHESIS_REVIEWER, ModelRole.ACTION_PLANNER} <= roles + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_with_the_switch_off_there_is_no_filler_call_and_no_record(tmp_path: Path) -> None: + runtime, model, rpc, thread_id, _ = await run(tmp_path, on=False) + try: + assert ModelRole.HYPOTHESIS_TABLE_FILLER not in {c.role for c in model.calls} + read = await rpc("thread/read", project_id="p", thread_id=thread_id, view="FULL") + assert "hypothesis_table_fill" not in read["current_result"]["result"] + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_the_filler_record_is_in_the_result_but_in_no_later_model_input( + tmp_path: Path, +) -> None: + runtime, model, rpc, thread_id, _ = await run(tmp_path, on=True) + try: + read = await rpc("thread/read", project_id="p", thread_id=thread_id, view="FULL") + # the public result keeps what the filler answered, cell by cell + assert len(read["current_result"]["result"]["hypothesis_table_fill"]["answers"]) == 3 + # the review and the action planning were called, and none of the calls (the generator, the + # reviewer, the planner) was shown that record: its answers and reasons are not evidence + roles = {c.role for c in model.calls} + assert {ModelRole.HYPOTHESIS_REVIEWER, ModelRole.ACTION_PLANNER} <= roles + for call in model.calls: + assert "hypothesis_table_fill" not in str(call.context_pack.research_context) + finally: + runtime.close() diff --git a/tests/integration/test_model_call_settings.py b/tests/integration/test_model_call_settings.py index eaf65ef..b7cb1bd 100644 --- a/tests/integration/test_model_call_settings.py +++ b/tests/integration/test_model_call_settings.py @@ -3,13 +3,25 @@ from pathlib import Path from typing import cast +import httpx import pytest from pydantic import BaseModel, JsonValue from tests.integration.storage_coverage_helpers import request, value from tests.integration.test_research_request_v2 import ControlledResearchModel, setup +from tests.unit.models.test_transport_diagnostic import ( + DiagnosticSession, + FailingStream, + failure_response, +) +from thoth.adapters.models.catalog import StaticModelCatalog +from thoth.adapters.models.codex_http import CodexHttpExecutor +from thoth.adapters.models.codex_oauth import CodexOAuthModel +from thoth.adapters.models.registry import RegisteredModelResolver +from thoth.apps.runtime import create_runtime from thoth.domain.model import ModelRequest, ModelResult from thoth.domain.model_call_settings import AUTO_RETRY_INTERRUPTED_DEFAULT +from thoth.domain.model_settings import ModelOption, ModelSelection from thoth.domain.research_execution import research_work Json = dict[str, JsonValue] @@ -26,8 +38,9 @@ async def structured[T: BaseModel](self, request: ModelRequest[T]) -> ModelResul return await super().structured(request) -def test_the_default_is_one_line_and_off() -> None: - assert AUTO_RETRY_INTERRUPTED_DEFAULT is False +def test_the_default_is_one_line_and_on() -> None: + # 사용자 결정 20261010-01: the default changed; explicit saved False remains valid. + assert AUTO_RETRY_INTERRUPTED_DEFAULT is True @pytest.mark.asyncio @@ -41,8 +54,9 @@ async def call(method: str, key: str, **more: JsonValue) -> Json: ) first = await call("model/callSettings/read", "r1") + # 사용자 결정 20261010-01: an unsaved project starts on. assert first == { - "auto_retry_interrupted_model_call": False, + "auto_retry_interrupted_model_call": True, "hypothesis_contract_v3": False, "settings_digest": None, } @@ -78,8 +92,106 @@ async def call(method: str, key: str, **more: JsonValue) -> Json: runtime.close() +@pytest.mark.parametrize("saved", [None, False, True]) +async def test_saved_retry_choice_survives_reopen_and_controls_actual_extra_sends( + tmp_path: Path, + saved: bool | None, +) -> None: + # 사용자 결정 20261010-01: only an absent choice defaults on; saved False means one send. + initial = await setup(tmp_path, ControlledResearchModel(), source=False) + try: + if saved is not None: + value( + await initial.bus.dispatch( + request( + "model/callSettings/update", + "save", + { + "project_id": "p", + "auto_retry_interrupted_model_call": saved, + "expected_digest": None, + }, + ) + ) + ) + finally: + initial.close() + + sends: list[httpx.Request] = [] + waits: list[float] = [] + + async def sleep(seconds: float) -> None: + waits.append(seconds) + + def respond(req: httpx.Request) -> httpx.Response: + sends.append(req) + return failure_response(FailingStream(httpx.RemoteProtocolError("peer closed connection"))) + + executor = CodexHttpExecutor(DiagnosticSession(), transport=httpx.MockTransport(respond)) + resolver = RegisteredModelResolver() + resolver.register( + "codex-oauth", lambda _: CodexOAuthModel(executor, sleep=sleep, jitter=lambda: 0.5) + ) + catalog = StaticModelCatalog( + ( + ModelOption( + provider="codex-oauth", + model="diagnostic-fixture", + reasoning_efforts=("high",), + default_effort="high", + capability_source="codex-test", + ), + ), + ModelSelection(provider="codex-oauth", model="diagnostic-fixture", reasoning_effort="high"), + ) + runtime = create_runtime(tmp_path, model_resolver=resolver, model_catalog=catalog) + try: + settings = value( + await runtime.bus.dispatch( + request("model/callSettings/read", "read", {"project_id": "p"}) + ) + ) + assert settings["auto_retry_interrupted_model_call"] is (True if saved is None else saved) + assert (settings["settings_digest"] is None) is (saved is None) + started = value( + await runtime.bus.dispatch( + request( + "thread/start", + "start", + { + "project_id": "p", + "problem": "Inspect connected records", + "contract_version": 2, + }, + ) + ) + ) + await runtime.bus.drain() + state = value( + await runtime.bus.query( + request( + "thread/read", + "result", + { + "project_id": "p", + "thread_id": started["thread_id"], + }, + ) + ) + ) + expected_sends = 1 if saved is False else 3 + assert len(sends) == expected_sends + assert waits == ([] if saved is False else [2.0, 4.0]) + dispatches = state["model_dispatches"] + assert isinstance(dispatches, list) and len(dispatches) == expected_sends + result = state["current_result"] + assert isinstance(result, dict) and result["terminal_reason"] == "OAUTH_TRANSPORT_FAILURE" + finally: + runtime.close() + + @pytest.mark.asyncio -async def test_an_investigation_sees_the_switch_only_when_the_project_turned_it_on( +async def test_an_investigation_sees_default_on_then_the_projects_saved_off_choice( tmp_path: Path, ) -> None: model = SeesTheSwitch() @@ -89,21 +201,22 @@ async def test_an_investigation_sees_the_switch_only_when_the_project_turned_it_ await runtime.bus.dispatch( request( "thread/start", - "start-off", + "start-on", {"project_id": "p", "problem": "왜 지연이 생기나요?", "contract_version": 2}, ) ) ) await runtime.bus.drain() - assert model.seen and not any(model.seen) + # 사용자 결정 20261010-01: normal entry consumes the new unsaved default. + assert model.seen and all(model.seen) value( await runtime.bus.dispatch( request( "model/callSettings/update", - "turn-on", + "turn-off", { "project_id": "p", - "auto_retry_interrupted_model_call": True, + "auto_retry_interrupted_model_call": False, "expected_digest": None, }, ) @@ -114,7 +227,7 @@ async def test_an_investigation_sees_the_switch_only_when_the_project_turned_it_ await runtime.bus.dispatch( request( "thread/input", - "input-on", + "input-off", { "project_id": "p", "thread_id": started["thread_id"], @@ -125,6 +238,7 @@ async def test_an_investigation_sees_the_switch_only_when_the_project_turned_it_ ) ) await runtime.bus.drain() - assert model.seen and all(model.seen) + # 사용자 결정 20261010-01: explicitly saved False overrides the default. + assert model.seen and not any(model.seen) finally: runtime.close() diff --git a/tests/integration/test_prepare_synthetic_radar_demo.py b/tests/integration/test_prepare_synthetic_radar_demo.py index 9e9018f..e93f6e2 100644 --- a/tests/integration/test_prepare_synthetic_radar_demo.py +++ b/tests/integration/test_prepare_synthetic_radar_demo.py @@ -85,9 +85,13 @@ def test_phase_1_connects_the_files_and_the_csv_points_at_real_sentences( done = prepare.prepare_phase1(api, DEMO, tmp_path / "files", "demo") assert done.mode == "CREATE" and done.preview["applicable"] sources = api.rpc("project/source/list", {"project_id": done.project_id}, query=True) - assert len(sources["artifacts"]) == 3 # requirements, test plan, dry result; no rain yet + # requirements, test plan, dry result and the trial file it names; no rain yet + assert len(sources["artifacts"]) == 4 listed = json.dumps(sources) assert "10_REQUIREMENTS_SYNTHETIC.yaml" in listed and "30_RESULT_DRY_SYNTHETIC.yaml" in listed + assert "30_DRY_TRIALS_SYNTHETIC.csv" in listed + names = " ".join(str(item["source_uri"]) for item in sources["artifacts"]) + assert "MANIFEST" not in names and "LINEAGE" not in names and ".schema." not in names assert ".txt" not in listed # the files keep their own names apply_import(api, done.project_id, "CREATE", done.csv_path) view = api.rpc("trace/read", {"project_id": done.project_id}, query=True) @@ -116,6 +120,9 @@ def test_phase_2_adds_only_the_rain_rows_and_the_requirement_fails_with_incomple apply_import(api, first.project_id, "CREATE", first.csv_path) second = prepare.prepare_phase2(api, DEMO, out) assert second.project_id == first.project_id and second.mode == "UPDATE" + sources = api.rpc("project/source/list", {"project_id": first.project_id}, query=True) + names = " ".join(str(item["source_uri"]) for item in sources["artifacts"]) + assert len(sources["artifacts"]) == 6 and "31_RAIN_TRIALS_SYNTHETIC.csv" in names changes = second.preview["changes"] assert changes["updated"] == [] and changes["deleted"] == [] assert sorted(change["kind"] for change in changes["added"]) == sorted( diff --git a/tests/integration/test_thread_cycle_preparation_contract.py b/tests/integration/test_thread_cycle_preparation_contract.py new file mode 100644 index 0000000..5e65a1a --- /dev/null +++ b/tests/integration/test_thread_cycle_preparation_contract.py @@ -0,0 +1,221 @@ +"""Normal research entry preserves draft projection, head preparation and protected call order.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from contextvars import ContextVar, copy_context +from dataclasses import replace +from pathlib import Path +from typing import cast + +import pytest +from pydantic import BaseModel +from tests.integration.storage_coverage_helpers import request, value +from tests.integration.test_research_request_v2 import ControlledResearchModel, setup + +from thoth.application.services.full_project_memory import ( + FullMemoryPromotionResult, + FullProjectMemoryService, +) +from thoth.application.services.revision_service import CommitResult +from thoth.application.workflows.thread_cycle import ( + ThreadCycleCommand, + ThreadCycleResult, + ThreadCycleService, +) +from thoth.domain.canonical import model_digest +from thoth.domain.enums import HypothesisStatus, ModelRole, PortfolioStatus +from thoth.domain.evidence_requirements import HypothesisSemanticReview +from thoth.domain.hypothesis import HypothesisPortfolio +from thoth.domain.memory import MemoryRecord +from thoth.domain.memory_preparation import MemoryPreparationBasis +from thoth.domain.model import ModelRequest, ModelResult +from thoth.domain.revision import StagedRevision +from thoth.ports.model import ModelOutputContractHold + +ACTIVE_CYCLE: ContextVar[bool] = ContextVar("test_active_preparation_cycle", default=False) + + +def in_transaction() -> bool: + return any( + item is not None + for key, item in copy_context().items() + if key.name == "thoth_sqlite_ambient_transaction" + ) + + +class ContractModel(ControlledResearchModel): + def __init__(self, events: list[str], *, one: bool, incomplete_review: bool) -> None: + super().__init__(one=one, tested=True) + self.events = events + self.incomplete_review = incomplete_review + self.generated: list[HypothesisPortfolio] = [] + self.reviewed: list[HypothesisPortfolio] = [] + + async def structured[T: BaseModel](self, request: ModelRequest[T]) -> ModelResult[T]: + if ACTIVE_CYCLE.get(): + assert not in_transaction() + self.events.append("model:" + request.role.value) + if request.role == ModelRole.HYPOTHESIS_REVIEWER: + candidate = request.context_pack.candidate_portfolio + assert candidate is not None + self.reviewed.append(candidate) + assert candidate.status is PortfolioStatus.DRAFT + assert all( + h.status is HypothesisStatus.DRAFT and h.semantic_review_ref + for h in candidate.hypotheses + ) + result = await super().structured(request) + assert not in_transaction() + if isinstance(result.output, HypothesisPortfolio): + promoted = result.output.model_copy( + update={ + "status": PortfolioStatus.TESTABLE, + "hypotheses": tuple( + h.model_copy( + update={ + "status": HypothesisStatus.TESTABLE, + "predicted_observations": ("Controlled expected observation",), + } + ) + for h in result.output.hypotheses + ), + } + ) + parsed = request.output_model.model_validate(promoted.model_dump(mode="python")) + assert isinstance(parsed, HypothesisPortfolio) + self.generated.append(parsed) + result = replace( + result, + output=parsed, + output_digest=model_digest("OUTPUT", parsed, schema_version="1.0.0"), + ) + elif self.incomplete_review and isinstance(result.output, HypothesisSemanticReview): + rejected = result.output.model_copy(update={"decisions": ()}) + result = replace( + result, + output=rejected, + output_digest=model_digest("OUTPUT", rejected, schema_version="1.0.0"), + ) + return result + + +@pytest.mark.parametrize( + ("one", "incomplete_review"), [(False, False), (True, False), (True, True)] +) +async def test_draft_projection_head_overrides_and_model_authority_order( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + one: bool, + incomplete_review: bool, +) -> None: + events: list[str] = [] + model = ContractModel(events, one=one, incomplete_review=incomplete_review) + runtime = await setup(tmp_path, model, source=True) + execute = ThreadCycleService.execute + authority = FullProjectMemoryService.require_authority + commit = cast( + Callable[ + ..., + Awaitable[ + tuple[CommitResult, tuple[MemoryRecord, ...], FullMemoryPromotionResult | None] + ], + ], + vars(ThreadCycleService)["_commit_cycle"], + ) + holds: list[str] = [] + results: list[ThreadCycleResult] = [] + expected_records: list[dict[str, str]] = [] + + async def observed_execute( + self: ThreadCycleService, command: ThreadCycleCommand + ) -> ThreadCycleResult: + heads = runtime.ledger.read_heads(command.project_id) + key = next(key for key in heads if key.startswith("THREAD:request:")) + overrides = {key: heads[key]} + command = replace(command, expected_head_overrides=overrides) + expected_records.append(dict(overrides)) + token = ACTIVE_CYCLE.set(True) + try: + result = await execute(self, command) + results.append(result) + return result + except ModelOutputContractHold as exc: + holds.append(str(exc)) + raise + finally: + assert command.expected_head_overrides == expected_records[-1] + ACTIVE_CYCLE.reset(token) + + def require_authority(self: FullProjectMemoryService, basis: MemoryPreparationBasis) -> None: + if ACTIVE_CYCLE.get(): + events.append("authority:inside" if in_transaction() else "authority:outside") + authority(self, basis) + + async def observed_commit(self: ThreadCycleService, **kwargs: object): + events.append("commit_cycle") + assert not in_transaction() + command = cast(ThreadCycleCommand, kwargs["command"]) + heads = cast(dict[str, str], kwargs["heads"]) + staged = cast(tuple[StagedRevision, ...], kwargs["staged"]) + expected = cast(dict[str, str], kwargs["expected_heads"]) + keys = {f"{item.revision.entity_type.value}:{item.revision.entity_id}" for item in staged} + assert set(expected) == (set(heads) & keys) | set(command.expected_head_overrides) + assert all( + expected[key] == digest for key, digest in command.expected_head_overrides.items() + ) + assert expected is not heads and expected is not command.expected_head_overrides + return await commit(self, **kwargs) + + monkeypatch.setattr(ThreadCycleService, "execute", observed_execute) + monkeypatch.setattr(ThreadCycleService, "_commit_cycle", observed_commit) + monkeypatch.setattr(FullProjectMemoryService, "require_authority", require_authority) + try: + value( + await runtime.bus.dispatch( + request( + "thread/start", + "preparation-contract", + { + "project_id": "p", + "problem": "Compare the connected condition", + "contract_version": 2, + }, + ) + ) + ) + await runtime.bus.drain() + assert len(model.generated) == len(model.reviewed) == 1 + original, candidate = model.generated[0], model.reviewed[0] + assert len(original.hypotheses) == len(candidate.hypotheses) == int(one) + assert original.status is PortfolioStatus.TESTABLE + assert all(h.status is HypothesisStatus.TESTABLE for h in original.hypotheses) + assert candidate.portfolio_id == original.portfolio_id + assert candidate.generated_from_head_set == original.generated_from_head_set + assert candidate.alternatives_considered == original.alternatives_considered + assert candidate.next_checks == original.next_checks + assert candidate.uncertainty_reserve == original.uncertainty_reserve + assert [h.hypothesis_id for h in candidate.hypotheses] == [ + h.hypothesis_id for h in original.hypotheses + ] + roles = [item for item in events if item.startswith("model:")] + expected_roles = ["model:HYPOTHESIS_GENERATOR", "model:HYPOTHESIS_REVIEWER"] + if not incomplete_review: + expected_roles.append("model:ACTION_PLANNER") + assert roles == expected_roles + assert events.index("model:HYPOTHESIS_GENERATOR") < events.index("authority:outside") + assert events.index("authority:outside") < events.index("model:HYPOTHESIS_REVIEWER") + if incomplete_review: + assert holds == ["HYPOTHESIS_REVIEW_COVERAGE_MISMATCH"] + assert not results and "commit_cycle" not in events and "authority:inside" not in events + assert not any( + k.startswith(("HYPOTHESIS:", "ACTION:")) for k in runtime.ledger.read_heads("p") + ) + else: + assert holds == [] and len(results) == 1 + assert events.index("model:ACTION_PLANNER") < events.index("commit_cycle") + assert events.index("commit_cycle") < events.index("authority:inside") + assert results[0].model_ids == ("CONTROLLED_RESEARCH", "CONTROLLED_RESEARCH") + assert results[0].semantic_repair_attempted is False + finally: + runtime.close() diff --git a/tests/integration/test_trace_origin_siblings.py b/tests/integration/test_trace_origin_siblings.py new file mode 100644 index 0000000..6275d35 --- /dev/null +++ b/tests/integration/test_trace_origin_siblings.py @@ -0,0 +1,264 @@ +"""A trace row starts an investigation with the same measure under the other conditions beside it. + +The siblings are found from the stored trace's links and rules (the criteria of the same +requirement with the same measure and another condition), never from names. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from tests.integration.test_research_request_v2 import ControlledResearchModel +from tests.integration.test_trace_origin import Rpc, opened, origin_for, with_origin + +from thoth.application.services.trace_csv import export_csv +from thoth.domain.trace_origin import ( + MAX_COMPARISON_REFS, + MAX_SIBLING_SPAN_REFS, + MAX_SIBLING_VERDICTS, +) +from thoth.domain.verification_trace import ( + Comparator, + CriterionRule, + ResultRecord, + TraceItem, + TraceKind, + TraceLink, + TraceRelation, + TraceSet, +) + +DRY, FOG, RAIN = "SYN-C-DET-DRY", "SYN-C-DET-FOG", "SYN-C-DET-RAIN" +FA_DRY = "SYN-C-FA-DRY" +T0 = datetime(2026, 10, 2, tzinfo=UTC) + + +def criterion_set( + requirements: dict[str, list[tuple[str, str, str, int]]], +) -> TraceSet: + """requirement -> [(criterion, measure, condition, number of result positions)]. + + A criterion with positions also has one result (value 0.95, 19/20) pointing at them. + """ + items: list[TraceItem] = [] + links: list[TraceLink] = [] + rules: list[CriterionRule] = [] + results: list[ResultRecord] = [] + for requirement, criteria in requirements.items(): + items.append( + TraceItem(item_id=requirement, item_key=f"k-{requirement}", kind=TraceKind.REQUIREMENT) + ) + for criterion, measure, condition, positions in criteria: + items.append( + TraceItem(item_id=criterion, item_key=f"k-{criterion}", kind=TraceKind.CRITERION) + ) + links.append( + TraceLink( + link_id=f"L-{criterion}", + from_id=criterion, + to_id=requirement, + relation=TraceRelation.REFINES, + ) + ) + rules.append( + CriterionRule( + rule_id=f"R-{criterion}", + criterion_id=criterion, + measure=measure, + comparator=Comparator.AT_LEAST, + threshold=Decimal("0.90"), + unit="ratio", + condition=condition, + ) + ) + if positions: + result = f"RES-{criterion}" + items.append( + TraceItem(item_id=result, item_key=f"k-{result}", kind=TraceKind.RESULT) + ) + results.append( + ResultRecord( + result_id=result, + criterion_id=criterion, + condition=condition, + value=Decimal("0.95"), + unit="ratio", + numerator=19, + denominator=20, + observed_at=T0, + source_span_refs=tuple(f"{criterion}#{n}" for n in range(positions)), + ) + ) + return TraceSet( + items=tuple(items), links=tuple(links), rules=tuple(rules), results=tuple(results) + ) + + +async def opened_with( + tmp_path: Path, trace_set: TraceSet +) -> tuple[Any, Rpc, ControlledResearchModel]: + from tests.integration.test_research_request_v2 import setup + + model = ControlledResearchModel() + tmp_path.mkdir(exist_ok=True) + runtime = await setup(tmp_path, model) + rpc = Rpc(runtime) + await rpc.load(export_csv(trace_set), "CREATE") + return runtime, rpc, model + + +async def investigate( + rpc: Rpc, runtime: Any, model: ControlledResearchModel, row: str +) -> dict[str, Any]: + origin = await origin_for(rpc, row) + await rpc( + "thread/start", + project_id="p", + problem="원인을 찾아 주세요.", + contract_version=2, + origin=origin, + ) + await runtime.bus.drain() + return with_origin(model)[0] + + +@pytest.mark.asyncio +async def test_the_row_carries_the_other_conditions_of_its_measure_with_their_values( + tmp_path: Path, +) -> None: + model = ControlledResearchModel() + runtime, rpc = await opened(tmp_path, model) + try: + seen = await investigate(rpc, runtime, model, RAIN) + siblings = {item["criterion_id"]: item for item in seen["sibling_verdicts"]} + assert list(siblings) == [ + DRY, + FOG, + ] # the one with a result first; the false-track rows are another measure + dry = siblings[DRY] + assert (dry["state"], dry["condition"], dry["value"]) == ( + "PASS_COMPUTED", + "weather=dry", + "0.95", + ) + assert (dry["numerator"], dry["denominator"], dry["unit"]) == (19, 20, "ratio") + assert dry["result_id"] == "SYN-RES-SYN-C-DET-DRY" and ">=" in dry["rule_summary"] + fog = siblings[FOG] + assert ( + fog["state"] == "HOLD_NO_RESULT" and fog["value"] is None and fog["result_id"] is None + ) + assert seen["comparison_span_refs"] == ["30_RESULT_DRY_SYNTHETIC.yaml#detection"] + assert "sibling" in seen["note"] + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_a_row_with_no_other_condition_of_its_measure_gets_nothing_added( + tmp_path: Path, +) -> None: + trace_set = criterion_set( + { + "REQ-1": [ + ("C-A", "detection_rate", "weather=dry", 1), + ("C-B", "false_track_rate", "weather=dry", 1), + ] + } + ) + runtime, rpc, model = await opened_with(tmp_path, trace_set) + try: + seen = await investigate(rpc, runtime, model, "C-A") + assert seen["sibling_verdicts"] == [] and seen["comparison_span_refs"] == [] + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_a_criterion_of_another_requirement_is_not_a_sibling(tmp_path: Path) -> None: + trace_set = criterion_set( + { + "REQ-1": [ + ("C-A", "detection_rate", "weather=dry", 1), + ("C-B", "detection_rate", "weather=rain", 1), + ], + "REQ-2": [("C-C", "detection_rate", "weather=fog", 1)], + } + ) + runtime, rpc, model = await opened_with(tmp_path, trace_set) + try: + seen = await investigate(rpc, runtime, model, "C-A") + assert [item["criterion_id"] for item in seen["sibling_verdicts"]] == ["C-B"] + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_siblings_and_their_positions_are_capped_and_those_with_results_come_first( + tmp_path: Path, +) -> None: + others = [(f"C-{n}", "detection_rate", f"weather=w{n}", 0 if n < 2 else 5) for n in range(1, 8)] + trace_set = criterion_set({"REQ-1": [("C-0", "detection_rate", "weather=w0", 1), *others]}) + runtime, rpc, model = await opened_with(tmp_path, trace_set) + try: + seen = await investigate(rpc, runtime, model, "C-0") + ids = [item["criterion_id"] for item in seen["sibling_verdicts"]] + assert len(ids) == MAX_SIBLING_VERDICTS == 4 + assert ids == ["C-2", "C-3", "C-4", "C-5"] # C-1 has no result; later ones fill the room + refs = seen["comparison_span_refs"] + assert len(refs) == MAX_COMPARISON_REFS + assert refs[:MAX_SIBLING_SPAN_REFS] == ["C-2#0", "C-2#1", "C-2#2"] # each sibling is capped + finally: + runtime.close() + + +@pytest.mark.asyncio +async def test_a_requirement_row_has_no_siblings_and_another_project_is_never_mixed_in( + tmp_path: Path, +) -> None: + model = ControlledResearchModel() + runtime, rpc = await opened(tmp_path, model) + try: + await rpc("project/create", project_id="q", name="other", cutoff_at="2026-09-13T00:00:00Z") + other = criterion_set( + { + "SYN-REQ-RAD-001": [ + (DRY, "detection_rate", "weather=dry", 1), + (RAIN, "detection_rate", "weather=rain", 1), + ] + } + ) + text = export_csv(other) + preview = await rpc("trace/importPreview", project_id="q", mode="CREATE", csv_text=text) + await rpc( + "trace/importApply", + project_id="q", + mode="CREATE", + csv_text=text, + preview_id=preview["preview_id"], + input_sha256=preview["input_sha256"], + ) + seen = await investigate(rpc, runtime, model, RAIN) + assert {item["criterion_id"] for item in seen["sibling_verdicts"]} == {DRY, FOG} + # the other project's own result is called RES-SYN-C-DET-DRY; this project's is not that + assert {item["result_id"] for item in seen["sibling_verdicts"]} == { + "SYN-RES-SYN-C-DET-DRY", + None, + } + requirement = await origin_for(rpc, "SYN-REQ-RAD-001", kind="REQUIREMENT") + await rpc( + "thread/start", + project_id="p", + problem="요구사항", + contract_version=2, + origin=requirement, + ) + await runtime.bus.drain() + row = with_origin(model)[-1] + assert row["subject_kind"] == "REQUIREMENT" + assert row["sibling_verdicts"] == [] and row["comparison_span_refs"] == [] + finally: + runtime.close() diff --git a/tests/integration/test_transport_diagnostic_persistence.py b/tests/integration/test_transport_diagnostic_persistence.py index de3ee48..a52dba3 100644 --- a/tests/integration/test_transport_diagnostic_persistence.py +++ b/tests/integration/test_transport_diagnostic_persistence.py @@ -34,13 +34,18 @@ async def test_normal_failure_keeps_diagnostic_private_and_thread_scoped_after_r initial = await setup(tmp_path, ControlledResearchModel(), source=False) initial.close() calls: list[httpx.Request] = [] - original = kind(f"{SECRETS[4]} https://example.invalid/?key={SECRETS[3]}") + original = kind( + f"peer closed connection {SECRETS[4]} https://example.invalid/?key={SECRETS[3]}" + ) original.__cause__ = OSError(104, SECRETS[4]) stream = FailingStream(original) def serve(req: httpx.Request) -> httpx.Response: calls.append(req) - return failure_response(stream) + response = failure_response(stream) + response.headers["x-oai-request-id"] = "req_diagnostic_1234" + response.headers["cf-ray"] = "abcdef0123456789-ICN" + return response executor = CodexHttpExecutor(DiagnosticSession(), transport=httpx.MockTransport(serve)) resolver = RegisteredModelResolver() @@ -86,6 +91,9 @@ def serve(req: httpx.Request) -> httpx.Response: projected = dispatches[0] diagnostic = projected["transport_observation"]["transport_diagnostic"] assert diagnostic["httpx_error_type"] == kind.__name__ + assert diagnostic["x_oai_request_id"] == "req_diagnostic_1234" + assert diagnostic["cf_ray"] == "abcdef0123456789-ICN" + assert diagnostic["error_message_prefix"].startswith("peer closed connection") assert diagnostic["nested_errno"] == 104 assert projected["remote_stop"] == "UNKNOWN" assert_private(json.dumps(state)) diff --git a/tests/unit/application/test_hypothesis_table_filler.py b/tests/unit/application/test_hypothesis_table_filler.py new file mode 100644 index 0000000..3ba9664 --- /dev/null +++ b/tests/unit/application/test_hypothesis_table_filler.py @@ -0,0 +1,528 @@ +"""The table-filling call: when it is made, what it may change, and what happens when it fails.""" + +from __future__ import annotations + +from asyncio import CancelledError +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +import pytest +from pydantic import BaseModel +from tests.unit.test_research_short_text_selection import source + +from thoth.application.services.hypothesis_table_filler import ( + TABLE_FILLER_MAX_OUTPUT_TOKENS, + TABLE_FILLER_VERSION, + accepted_portfolio, + count_cells, + fill_tables, + merge_fill, +) +from thoth.domain.canonical import model_digest +from thoth.domain.enums import ( + CausalDepth, + HypothesisStatus, + ModelRole, + PortfolioStatus, + Reversibility, + RiskTier, +) +from thoth.domain.evidence import EvidenceSpan +from thoth.domain.hypothesis import ( + ExpectedResult, + FilledRow, + FilledTest, + HypothesisPortfolio, + HypothesisPortfolioV3, + HypothesisTableFill, +) +from thoth.domain.model import ContextPack, ModelRequest, ModelResult +from thoth.domain.research_execution import ResearchFence +from thoth.domain.research_projection import DroppedExpectation +from thoth.ports.model import ModelExecutionHold + +HEAD = "a" * 64 +KOREAN = '「SYN-C-DET-RAIN」 기준의 판정이 "미달"입니다. 원인 후보를 찾아 주세요.' +IDS = ("h1", "h2", "h3") +SAME = "같은 결과가 남는다" +WORSE = "탐지가 더 나빠진다" + + +def hypothesis(owner: str, table: dict[str, str]) -> dict[str, Any]: + return { + "hypothesis_id": owner, + "object_id": "object:o", + "statement": f"cause {owner}", + "observed_problem": "a problem", + "primary_locus": None, + "causal_depth": CausalDepth.UNDETERMINED, + "scope_conditions": {"condition": "alpha"}, + "support_evidence_refs": ("span:0",), + "counterevidence_refs": (), + "counterevidence_queries": ("look for a counterexample",), + "assumptions": (), + "uncertainty": "unvalidated", + "predicted_observations": (f"observation of {owner}",), + "discriminating_tests": ( + { + "test_id": f"t-{owner}", + "procedure_candidate": "compare", + "expected_if_true": "worse", + "expected_if_alternative": "same", + "risk_tier": RiskTier.R0, + "reversibility": Reversibility.FULL, + "expected_by_hypothesis": tuple( + {"hypothesis_id": target, "expected": label, "basis": ()} + for target, label in table.items() + ), + }, + ), + "status": HypothesisStatus.DRAFT, + } + + +def portfolio(tables: dict[str, dict[str, str]]) -> HypothesisPortfolioV3: + return HypothesisPortfolioV3.model_validate( + { + "portfolio_id": "portfolio:o", + "object_id": "object:o", + "hypotheses": tuple(hypothesis(owner, tables.get(owner, {})) for owner in IDS), + "status": PortfolioStatus.DRAFT, + "generated_from_head_set": HEAD, + "alternatives_considered": ("another",), + "next_checks": ("check",), + "uncertainty_reserve": "unknown", + } + ) + + +def table_of(result: HypothesisPortfolio, owner: str) -> dict[str, str]: + (hyp,) = [item for item in result.hypotheses if item.hypothesis_id == owner] + (test,) = hyp.discriminating_tests + return {row.hypothesis_id: row.expected for row in test.expected_by_hypothesis} # type: ignore[attr-defined] + + +def row( + owner: str, target: str, expected: str, *basis: str, reason: str | None = None +) -> FilledRow: + return FilledRow(hypothesis_id=target, expected=expected, basis=basis, unknown_reason=reason) + + +def fill_of(owner: str, *rows: FilledRow) -> FilledTest: + return FilledTest(designed_for=owner, test_id=f"t-{owner}", rows=rows) + + +def dropped(result: HypothesisPortfolio, owner: str) -> list[tuple[str, str, str]]: + (hyp,) = [item for item in result.hypotheses if item.hypothesis_id == owner] + return [(d.test_id, d.hypothesis_id, d.reason) for d in hyp.dropped] # type: ignore[attr-defined] + + +SPANS = {span.span_id for span in source(3)} + + +def merged( + start: HypothesisPortfolioV3, *tests: FilledTest, problem: str = KOREAN +) -> tuple[HypothesisPortfolioV3, int]: + result, changed, _ = merge_fill(start, HypothesisTableFill(tests=tests), SPANS, problem) + return result, changed + + +# --- the merge rule ----------------------------------------------------------------------------- + + +def test_only_missing_and_unknown_cells_change_and_the_designed_for_cell_stays() -> None: + start = portfolio({"h1": {"h1": WORSE, "h2": "모름"}, "h2": {"h2": WORSE, "h3": SAME}}) + result, changed = merged( + start, + fill_of("h1", row("h1", "h2", SAME), row("h1", "h3", SAME)), # h2 was 모름, h3 missing + fill_of("h2", row("h2", "h3", "다른 표현"), row("h2", "h2", "다른 값")), + ) + assert table_of(result, "h1") == {"h1": WORSE, "h2": SAME, "h3": SAME} + # a cell the generator already filled is not overwritten, and neither is a designed-for cell + assert table_of(result, "h2") == {"h2": WORSE, "h3": SAME} + assert changed == 2 + assert {reason for _, _, reason in dropped(result, "h2")} == {"TABLE_FILL_CELL_NOT_OPEN"} + + +def test_a_designed_for_cell_that_is_missing_or_unknown_is_left_as_the_generator_wrote_it() -> None: + start = portfolio({"h1": {"h1": "모름", "h2": "모름"}}) + result, changed = merged(start, fill_of("h1", row("h1", "h1", WORSE), row("h1", "h2", SAME))) + assert table_of(result, "h1") == {"h1": "모름", "h2": SAME} and changed == 1 + assert dropped(result, "h1") == [("t-h1", "h1", "TABLE_FILL_CELL_NOT_OPEN")] + + +def test_an_unknown_answer_for_a_missing_cell_makes_the_cell_explicit_and_is_not_a_change() -> None: + start = portfolio({"h1": {"h1": WORSE}}) + result, changed = merged(start, fill_of("h1", row("h1", "h2", "모름"), row("h1", "h3", SAME))) + assert table_of(result, "h1") == {"h1": WORSE, "h2": "모름", "h3": SAME} + assert changed == 1 + + +def test_an_unknown_answer_leaves_an_unknown_cell_unknown() -> None: + start = portfolio({"h1": {"h1": WORSE, "h2": "모름"}}) + result, changed = merged(start, fill_of("h1", row("h1", "h2", "모름"))) + assert table_of(result, "h1") == {"h1": WORSE, "h2": "모름"} and changed == 0 + + +def test_existing_rows_keep_their_place_and_new_ones_follow_in_portfolio_order() -> None: + start = portfolio({"h1": {"h3": SAME, "h1": WORSE}}) + result, _ = merged(start, fill_of("h1", row("h1", "h2", SAME))) + assert list(table_of(result, "h1")) == ["h3", "h1", "h2"] + + +def test_an_answer_that_cannot_be_kept_is_dropped_alone_and_recorded_on_its_test() -> None: + start = portfolio({"h1": {"h1": WORSE}}) + result, changed = merged( + start, + fill_of( + "h1", + row("h1", "h2", SAME), # kept + row("h1", "h3", SAME, "span:nowhere"), + row("h1", "h3", "x" * 90), + row("h1", "h3", "탐지가 下降한다"), + row("h1", "h3", "gets worse"), + row("h1", "nobody", SAME), + row("h1", "h2", WORSE), # a second answer for the same cell + ), + FilledTest(designed_for="h1", test_id="no-such-test", rows=(row("h1", "h2", SAME),)), + ) + assert table_of(result, "h1") == {"h1": WORSE, "h2": SAME} and changed == 1 + reasons = [(h, reason) for _, h, reason in dropped(result, "h1")] + assert reasons == [ + ("h3", "UNKNOWN_SPAN"), + ("h3", "EXPECTED_TOO_LONG"), + ("h3", "EXPECTED_SCRIPT_MISMATCH"), + ("h3", "EXPECTED_LANGUAGE_MISMATCH"), + ("nobody", "UNKNOWN_HYPOTHESIS"), + ("h2", "DUPLICATE_HYPOTHESIS"), + ("", "UNKNOWN_TEST"), + ] + texts = {d.reason: d.text for d in result.hypotheses[0].dropped} # type: ignore[attr-defined] + assert texts["EXPECTED_LANGUAGE_MISMATCH"] == "gets worse" + + +def test_an_english_question_takes_labels_in_any_language() -> None: + start = portfolio({"h1": {"h1": WORSE}}) + result, changed = merged( + start, fill_of("h1", row("h1", "h2", "gets worse")), problem="Why did detection fail?" + ) + assert table_of(result, "h1")["h2"] == "gets worse" and changed == 1 + + +def test_the_basis_keeps_known_spans_and_at_most_five() -> None: + start = portfolio({"h1": {"h1": WORSE}}) + result, _ = merged(start, fill_of("h1", row("h1", "h2", SAME, "span:0", "span:1"))) + (hyp,) = result.hypotheses[:1] + rows = hyp.discriminating_tests[0].expected_by_hypothesis # type: ignore[attr-defined] + assert [r.basis for r in rows if r.hypothesis_id == "h2"] == [("span:0", "span:1")] + + +def test_the_cells_are_counted_over_every_test_and_hypothesis() -> None: + start = portfolio({"h1": {"h1": WORSE, "h2": "모름"}, "h2": {"h2": WORSE}}) + assert count_cells(start) == (9, 7) # h1: h2 and h3 open; h2: h1 and h3; h3: all three + result, _ = merged(start, fill_of("h1", row("h1", "h2", SAME), row("h1", "h3", SAME))) + assert count_cells(result) == (9, 5) + + +# --- the call ----------------------------------------------------------------------------------- + + +@dataclass +class Command: + evidence: tuple[EvidenceSpan, ...] = field(default_factory=lambda: tuple(source(3))) + cutoff_at: datetime = datetime(2026, 10, 10, tzinfo=UTC) + model_policy_ref: str = "policy:1" + + +def context(command: Command) -> ContextPack: + return ContextPack( + case_id="case", + project_id="p", + object_id="object:o", + problem=KOREAN, + evidence=command.evidence, + criteria=(), + sufficiency=None, + input_head_set_digest=HEAD, + research_context={"big": "the whole research context of the generator call"}, + ) + + +class FillerModel: + def __init__(self, answer: HypothesisTableFill | BaseException) -> None: + self.answer = answer + self.calls: list[ModelRequest[Any]] = [] + + async def structured(self, request: ModelRequest[Any]) -> ModelResult[Any]: + self.calls.append(request) + if isinstance(self.answer, BaseException): + raise self.answer + parsed: BaseModel = request.output_model.model_validate(self.answer.model_dump()) + return ModelResult( + output=parsed, + model_id="CONTROLLED", + prompt_version=request.prompt_version, + scripted=True, + input_digest=model_digest("INPUT", request.context_pack, schema_version="1.0.0"), + output_digest=model_digest("OUTPUT", parsed, schema_version="1.0.0"), + ) + + +async def filled(model: FillerModel, start: HypothesisPortfolio) -> HypothesisPortfolio: + command = Command() + return await fill_tables(model, start, context(command), command) # type: ignore[arg-type] + + +OPEN = {"h1": {"h1": WORSE, "h2": "모름"}} + + +@pytest.mark.asyncio +async def test_the_call_asks_for_the_open_cells_only_and_the_record_says_what_changed() -> None: + model = FillerModel(HypothesisTableFill(tests=(fill_of("h1", row("h1", "h2", SAME)),))) + result = await filled(model, portfolio(OPEN)) + (call,) = model.calls + assert call.role == ModelRole.HYPOTHESIS_TABLE_FILLER + assert call.prompt_version == TABLE_FILLER_VERSION and call.output_model is HypothesisTableFill + assert call.max_output_tokens == TABLE_FILLER_MAX_OUTPUT_TOKENS < 4_000 + sent = call.context_pack.research_context + assert "big" not in sent # the generator's research context is not sent again + tests = sent["table_filler"]["tests"] # type: ignore[index] + assert {(t["designed_for"], tuple(t["open_hypothesis_ids"])) for t in tests} == { + ("h1", ("h2", "h3")), + ("h2", ("h1", "h3")), + ("h3", ("h1", "h2")), + } + assert call.context_pack.problem == KOREAN and call.context_pack.evidence + record = result.table_fill # type: ignore[attr-defined] + assert (record.state, record.reason_code, record.changed_cells) == ("CALLED", None, 1) + # every cell it was asked about has one entry: one value, the rest given no row + assert [a.outcome for a in record.answers].count("CHANGED") == 1 + assert [a.outcome for a in record.answers].count("NOT_ANSWERED") == 5 + assert len(record.answers) == 6 # the designed-for cells of the other tests are not asked + assert (record.cells, record.open_before, record.open_after) == (9, 8, 7) + assert table_of(result, "h1") == {"h1": WORSE, "h2": SAME} + + +@pytest.mark.asyncio +async def test_a_full_table_makes_no_call_and_says_so() -> None: + full = {owner: {target: WORSE if owner == target else SAME for target in IDS} for owner in IDS} + model = FillerModel(HypothesisTableFill()) + result = await filled(model, portfolio(full)) + assert model.calls == [] + record = result.table_fill # type: ignore[attr-defined] + assert (record.state, record.cells, record.open_before, record.open_after) == ( + "SKIPPED_FULL", + 9, + 0, + 0, + ) + + +@pytest.mark.asyncio +async def test_one_hypothesis_or_a_v2_portfolio_makes_no_call_and_changes_nothing() -> None: + model = FillerModel(HypothesisTableFill()) + one = portfolio(OPEN).model_copy(update={"hypotheses": portfolio(OPEN).hypotheses[:1]}) + assert await filled(model, one) is one + v2 = HypothesisPortfolio.model_validate( + {**portfolio(OPEN).model_dump(), "hypotheses": ()}, + ) + assert await filled(model, v2) is v2 + assert model.calls == [] + assert getattr(one, "table_fill", None) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("error", "state", "reason"), + [ + (ModelExecutionHold("OAUTH_TRANSPORT_FAILURE"), "FAILED", "OAUTH_TRANSPORT_FAILURE"), + (ModelExecutionHold("SERIALIZED_MODEL_INPUT_LIMIT"), "SKIPPED_BUDGET", None), + (ModelExecutionHold("RESEARCH_BUDGET_MISSING"), "SKIPPED_BUDGET", None), + ], +) +async def test_a_call_that_fails_or_hits_a_limit_leaves_the_table_and_records_why( + error: BaseException, state: str, reason: str | None +) -> None: + start = portfolio(OPEN) + result = await filled(FillerModel(error), start) + record = result.table_fill # type: ignore[attr-defined] + assert record.state == state and record.reason_code == (reason or str(error)) + assert (record.changed_cells, record.open_before, record.open_after) == (0, 8, 8) + assert record.answers == () + assert [table_of(result, owner) for owner in IDS] == [table_of(start, owner) for owner in IDS] + + +@pytest.mark.asyncio +async def test_an_answer_that_does_not_fit_the_form_is_a_failure_not_a_crash() -> None: + class BadForm(FillerModel): + async def structured(self, request: ModelRequest[Any]) -> ModelResult[Any]: + request.output_model.model_validate({"tests": [{"unexpected": 1}]}) + raise AssertionError("not reached") + + result = await filled(BadForm(HypothesisTableFill()), portfolio(OPEN)) + record = result.table_fill # type: ignore[attr-defined] + assert (record.state, record.reason_code) == ("FAILED", "TABLE_FILL_OUTPUT_INVALID") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error", [CancelledError(), ResearchFence("CANCELLED_OR_THREAD_STOPPED")]) +async def test_a_stopped_research_is_not_swallowed(error: BaseException) -> None: + with pytest.raises(type(error)): + await filled(FillerModel(error), portfolio(OPEN)) + + +@pytest.mark.asyncio +async def test_bad_generator_rows_are_settled_before_the_call_asks_for_the_cells() -> None: + generated = portfolio({"h1": {"h1": WORSE, "h2": "모름"}}) + bad = ( + generated.hypotheses[0] + .discriminating_tests[0] + .model_copy( + update={ + "expected_by_hypothesis": ( + *generated.hypotheses[0].discriminating_tests[0].expected_by_hypothesis, + ExpectedResult(hypothesis_id="h3", expected="x", basis=("span:made-up",)), + ) + } + ) + ) + first = generated.hypotheses[0].model_copy(update={"discriminating_tests": (bad,)}) + generated = generated.model_copy(update={"hypotheses": (first, *generated.hypotheses[1:])}) + model = FillerModel(HypothesisTableFill(tests=(fill_of("h1", row("h1", "h3", SAME)),))) + command = Command() + result = await accepted_portfolio(model, generated, context(command), command, False) # type: ignore[arg-type] + (call,) = model.calls + sent = call.context_pack.research_context["table_filler"]["tests"] # type: ignore[index] + one = next(t for t in sent if t["designed_for"] == "h1") + assert one["open_hypothesis_ids"] == ["h2", "h3"] # the made-up row was settled away first + assert table_of(result, "h1")["h3"] == SAME + assert ("t-h1", "h3", "UNKNOWN_SPAN") in dropped(result, "h1") + + +# --- what the filler said for each cell it was asked about --------------------------------------- + + +def answers_of( + start: HypothesisPortfolioV3, *tests: FilledTest +) -> dict[tuple[str, str], tuple[str, str | None, str]]: + """(test id, hypothesis id) -> (expected, unknown_reason, outcome) for every asked cell.""" + _, _, found = merge_fill(start, HypothesisTableFill(tests=tests), SPANS, KOREAN) + return {(a.test_id, a.hypothesis_id): (a.expected, a.unknown_reason, a.outcome) for a in found} + + +def test_each_asked_cell_gets_one_outcome_and_unasked_cells_get_none() -> None: + start = portfolio({"h1": {"h1": WORSE, "h2": "모름"}, "h2": {"h2": WORSE, "h3": SAME}}) + reason = "설정을 바꾸면 어느 쪽 결과도 나올 수 있다" + found = answers_of( + start, + fill_of( + "h1", + row("h1", "h2", "모름", reason=reason), # asked, still unknown, with its reason + row("h1", "h3", SAME), # asked (missing), now a value + row("h1", "h1", WORSE), # the designed-for cell was not asked + ), + fill_of("h2", row("h2", "h1", SAME, "span:nowhere")), # asked, answer cannot be kept + ) + assert found[("t-h1", "h2")] == ("모름", reason, "STILL_UNKNOWN") + assert found[("t-h1", "h3")] == (SAME, None, "CHANGED") + assert found[("t-h2", "h1")] == (SAME, None, "DROPPED") + # asked but given no row: NOT_ANSWERED (the h3 test has two open cells, none answered) + assert {key for key in found if key[0] == "t-h3"} == {("t-h3", "h1"), ("t-h3", "h2")} + assert {value[2] for key, value in found.items() if key[0] == "t-h3"} == {"NOT_ANSWERED"} + assert ("t-h1", "h1") not in found # not asked, so absent (it is only recorded as dropped) + + +def test_a_reason_is_kept_only_for_an_unknown_answer_and_is_cut_to_200_characters() -> None: + start = portfolio({"h1": {"h1": WORSE}}) + long = "이유 " * 300 + found = answers_of( + start, + fill_of( + "h1", + row("h1", "h2", "모름", reason=long), + row("h1", "h3", SAME, reason="값을 쓸 때는 이유를 쓰지 않는다"), + ), + ) + assert found[("t-h1", "h2")][1] == long.strip()[:200] + assert found[("t-h1", "h3")][1] is None + + +def test_a_kept_second_answer_replaces_a_dropped_first_one_and_a_repeat_changes_nothing() -> None: + start = portfolio({"h1": {"h1": WORSE}}) + found = answers_of( + start, + fill_of( + "h1", + row("h1", "h2", "x" * 90), # too long: dropped + row("h1", "h2", SAME), # kept: replaces the dropped one + row("h1", "h3", SAME), + row("h1", "h3", WORSE), # a repeat of a kept answer: the first stays + ), + ) + assert found[("t-h1", "h2")][2] == "CHANGED" + assert found[("t-h1", "h3")] == (SAME, None, "CHANGED") + + +def test_a_cell_that_does_not_fit_in_the_table_is_reported_as_dropped() -> None: + names = [f"x{n}" for n in range(10)] + full = hypothesis("h1", {name: SAME for name in names}) + other = hypothesis("h2", {}) + base = HypothesisPortfolioV3.model_validate( + {**portfolio({}).model_dump(), "hypotheses": (full, other)} + ) + # ten rows already name hypotheses that are not in this portfolio; the eleventh does not fit + found = answers_of(base, fill_of("h1", row("h1", "h2", SAME))) + assert found[("t-h1", "h2")][2] == "DROPPED" + + +# --- a designed-for cell that settling dropped for its language --------------------------------- + + +def with_drop(start: HypothesisPortfolioV3, owner: str, target: str, reason: str) -> None: + (item,) = [h for h in start.hypotheses if h.hypothesis_id == owner] + item.note_dropped( + (DroppedExpectation(test_id=f"t-{owner}", hypothesis_id=target, reason=reason, text="x"),) + ) + + +def test_a_designed_for_cell_dropped_for_its_language_may_be_filled_by_the_call() -> None: + start = portfolio({"h1": {"h2": SAME, "h3": SAME}}) + with_drop(start, "h1", "h1", "EXPECTED_SCRIPT_MISMATCH") + result, changed = merged(start, fill_of("h1", row("h1", "h1", WORSE))) + assert table_of(result, "h1") == {"h2": SAME, "h3": SAME, "h1": WORSE} and changed == 1 + # the drop that opened the cell stays on the record + assert ("t-h1", "h1", "EXPECTED_SCRIPT_MISMATCH") in dropped(result, "h1") + + +def test_a_designed_for_cell_stays_closed_unless_a_language_drop_removed_its_label() -> None: + for reason, target in ( + ("UNKNOWN_SPAN", "h1"), # dropped for another reason + ("EXPECTED_SCRIPT_MISMATCH", "h2"), # a language drop of another hypothesis's cell + ("DUPLICATE_HYPOTHESIS", "h1"), + ): + start = portfolio({"h1": {"h2": SAME, "h3": SAME}}) + with_drop(start, "h1", target, reason) + result, changed = merged(start, fill_of("h1", row("h1", "h1", WORSE))) + assert "h1" not in table_of(result, "h1") and changed == 0 + assert ("t-h1", "h1", "TABLE_FILL_CELL_NOT_OPEN") in dropped(result, "h1") + + +def test_a_designed_for_cell_that_has_its_label_is_not_opened_by_an_old_language_drop() -> None: + start = portfolio({"h1": {"h1": WORSE, "h2": SAME, "h3": SAME}}) + with_drop(start, "h1", "h1", "EXPECTED_LANGUAGE_MISMATCH") + result, changed = merged(start, fill_of("h1", row("h1", "h1", SAME))) + assert table_of(result, "h1")["h1"] == WORSE and changed == 0 + + +@pytest.mark.asyncio +async def test_the_call_asks_for_a_designed_for_cell_that_a_language_drop_emptied() -> None: + start = portfolio({"h1": {"h2": SAME}}) + with_drop(start, "h1", "h1", "EXPECTED_SCRIPT_MISMATCH") + model = FillerModel(HypothesisTableFill(tests=(fill_of("h1", row("h1", "h1", WORSE)),))) + result = await filled(model, start) + (call,) = model.calls + tests = call.context_pack.research_context["table_filler"]["tests"] # type: ignore[index] + asked = {t["designed_for"]: tuple(t["open_hypothesis_ids"]) for t in tests} + assert asked["h1"] == ("h1", "h3") and asked["h2"] == ("h1", "h3") + assert table_of(result, "h1")["h1"] == WORSE diff --git a/tests/unit/domain/test_expected_label_language_settle.py b/tests/unit/domain/test_expected_label_language_settle.py new file mode 100644 index 0000000..c4f4674 --- /dev/null +++ b/tests/unit/domain/test_expected_label_language_settle.py @@ -0,0 +1,88 @@ +"""A table label in the wrong language or script is dropped and recorded for a Korean question. + +Settling has no model call. A label with no Hangul, or with Chinese characters or kana the question +itself does not have, is dropped alone with its original text; a question without Hangul leaves +every label as it was. A dropped cell is then open for the table-filling call. +""" + +from __future__ import annotations + +from typing import Any + +from tests.unit.domain.test_refutation_language_settle import ( + ENGLISH_QUESTION, + KOREAN_QUESTION, + _hypothesis, + _portfolio, +) + +from thoth.application.services.hypothesis_contract_v3 import settle_contract +from thoth.domain.hypothesis import HypothesisPortfolioV3 + +GOOD = "탐지가 더 나빠진다" +KANA = "정답 대응また는 집계 오류로 과소 집계됨" +HANJA = "確認된 설정에서 누락이 늘어남" +NO_HANGUL = "stays slow" + + +def _with_table(hypothesis_id: str, rows: dict[str, str]) -> dict[str, Any]: + item = _hypothesis(hypothesis_id, ()) + (test,) = item["discriminating_tests"] + test["expected_by_hypothesis"] = tuple( + {"hypothesis_id": target, "expected": label, "basis": ()} for target, label in rows.items() + ) + return item + + +def _settled(problem: str, rows: dict[str, str]) -> HypothesisPortfolioV3: + others = [_with_table(f"h{number}", {}) for number in range(2, 6)] + portfolio = _portfolio(_with_table("h1", rows), *others) + settled = settle_contract(portfolio, (), problem=problem) + assert isinstance(settled, HypothesisPortfolioV3) + return settled + + +def _table(settled: HypothesisPortfolioV3) -> dict[str, str]: + (test,) = settled.hypotheses[0].discriminating_tests + return {row.hypothesis_id: row.expected for row in test.expected_by_hypothesis} + + +def test_a_korean_question_drops_labels_with_kana_chinese_characters_or_no_hangul() -> None: + settled = _settled( + KOREAN_QUESTION, + {"h1": GOOD, "h2": KANA, "h3": HANJA, "h4": NO_HANGUL, "h5": "모름"}, + ) + # the good label and 모름 stay; the three that are not Korean are gone, each recorded as it was + assert _table(settled) == {"h1": GOOD, "h5": "모름"} + assert [ + (d.test_id, d.hypothesis_id, d.reason, d.text) for d in settled.hypotheses[0].dropped + ] == [ + ("t1", "h2", "EXPECTED_SCRIPT_MISMATCH", KANA), + ("t1", "h3", "EXPECTED_SCRIPT_MISMATCH", HANJA), + ("t1", "h4", "EXPECTED_LANGUAGE_MISMATCH", NO_HANGUL), + ] + + +def test_the_designed_for_cell_is_dropped_the_same_way_and_the_drop_names_that_cell() -> None: + settled = _settled(KOREAN_QUESTION, {"h1": KANA, "h2": GOOD}) + assert _table(settled) == {"h2": GOOD} + (drop,) = settled.hypotheses[0].dropped + assert (drop.hypothesis_id, drop.reason) == ("h1", "EXPECTED_SCRIPT_MISMATCH") + + +def test_a_question_without_hangul_or_with_the_script_itself_drops_no_label() -> None: + rows = {"h1": GOOD, "h2": KANA, "h3": HANJA, "h4": NO_HANGUL} + for problem in (ENGLISH_QUESTION, "", "検出基準が不合格です。"): + settled = _settled(problem, rows) + assert _table(settled) == rows + assert settled.hypotheses[0].dropped == () + with_kana = _settled(KOREAN_QUESTION + " また", {"h1": KANA}) + assert _table(with_kana) == {"h1": KANA} + + +def test_a_label_dropped_for_its_language_is_not_also_dropped_for_something_else() -> None: + # an unknown hypothesis is dropped for that reason first, with no text + settled = _settled(KOREAN_QUESTION, {"h1": GOOD, "ghost": KANA}) + assert [(d.hypothesis_id, d.reason, d.text) for d in settled.hypotheses[0].dropped] == [ + ("ghost", "UNKNOWN_HYPOTHESIS", None) + ] diff --git a/tests/unit/domain/test_hypothesis_contract_v3.py b/tests/unit/domain/test_hypothesis_contract_v3.py index a5bdc4c..0148749 100644 --- a/tests/unit/domain/test_hypothesis_contract_v3.py +++ b/tests/unit/domain/test_hypothesis_contract_v3.py @@ -177,7 +177,7 @@ def test_the_contract_is_v2_unless_the_work_asks_for_v3() -> None: on = generator_contract(True) assert (off["prompt_version"], off["output_model"]) == (CONTRACT_V2, HypothesisPortfolio) assert (on["prompt_version"], on["output_model"]) == (CONTRACT_V3, HypothesisPortfolioV3) - assert CONTRACT_V2 == "hypothesis_portfolio.v2" and CONTRACT_V3 == "hypothesis_portfolio.v3" + assert CONTRACT_V2 == "hypothesis_portfolio.v2" and CONTRACT_V3 == "hypothesis_portfolio.v3.3" def _filled(*rows: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/unit/domain/test_refutation_language_settle.py b/tests/unit/domain/test_refutation_language_settle.py new file mode 100644 index 0000000..3fccb28 --- /dev/null +++ b/tests/unit/domain/test_refutation_language_settle.py @@ -0,0 +1,182 @@ +"""A refutation condition with no Hangul is dropped, and recorded, when the question is Korean. + +The v3 settling step has no model call: it only keeps what is in the question's language. A +question without Hangul (for example English) leaves every condition as it was. +""" + +from __future__ import annotations + +from typing import Any + +from thoth.application.services.hypothesis_contract_v3 import ( + DroppedExpectation, + settle_contract, +) +from thoth.domain.enums import ( + CausalDepth, + HypothesisStatus, + PortfolioStatus, + Reversibility, + RiskTier, +) +from thoth.domain.hypothesis import HypothesisPortfolioV3 + +HEAD = "a" * 64 +MISMATCH = "REFUTATION_LANGUAGE_MISMATCH" +KOREAN_QUESTION = '「SYN-C-DET-RAIN」 기준의 판정이 "미달"입니다. 원인 후보를 찾아 주세요.' +THIRD = ( + "관련 설정을 충분히 비교했는데도 실패가 설정과 무관하게 유지되면, " + "검토한 설정 범위에서 이 후보를 제외한다." +) +FOURTH = ( + "관련 대상·조건의 구성이 같고 충분한 비교에서도 구성에 따른 차이가 없으면, " + "검토한 구성 범위에서 이 후보를 제외한다." +) +FIFTH = ( + "자료가 무작위 생성이나 표본 추출 없이 고정 수치를 기입한 예시로 확인되면, " + "우연 변동 후보를 제외한다.", + "사전에 정한 충분한 독립 반복에서 지속적 저하가 확인되고 " + "우연 변동만으로 설명할 수 없으면, 단일 실행 변동만의 설명을 제외한다.", +) +ENGLISH_QUESTION = "The detection criterion failed. Find the cause candidates and tests." + + +def _hypothesis(hypothesis_id: str, conditions: tuple[str, ...]) -> dict[str, Any]: + return { + "hypothesis_id": hypothesis_id, + "object_id": "object:o", + "statement": "처리 설정이 원인일 수 있다", + "observed_problem": "a problem", + "primary_locus": None, + "causal_depth": CausalDepth.UNDETERMINED, + "scope_conditions": {"condition": "alpha"}, + "support_evidence_refs": ("span:a",), + "counterevidence_refs": (), + "counterevidence_queries": ("반례 기록을 찾는다",), + "assumptions": (), + "uncertainty": "아직 확인되지 않았다", + "predicted_observations": (), + "discriminating_tests": ( + { + "test_id": "t1", + "procedure_candidate": "재처리해 비교한다", + "expected_if_true": "누락", + "expected_if_alternative": "유지", + "risk_tier": RiskTier.R0, + "reversibility": Reversibility.FULL, + }, + ), + "status": HypothesisStatus.DRAFT, + "refutation_conditions": conditions, + } + + +def _portfolio(*hypotheses: dict[str, Any]) -> HypothesisPortfolioV3: + return HypothesisPortfolioV3.model_validate( + { + "portfolio_id": "portfolio:o", + "object_id": "object:o", + "hypotheses": hypotheses, + "status": PortfolioStatus.DRAFT, + "generated_from_head_set": HEAD, + "alternatives_considered": ("another candidate",), + "next_checks": ("check the records first",), + "uncertainty_reserve": "unknown", + } + ) + + +def _settled(portfolio: HypothesisPortfolioV3, problem: str) -> HypothesisPortfolioV3: + settled = settle_contract(portfolio, (), problem=problem) + assert isinstance(settled, HypothesisPortfolioV3) + return settled + + +def test_a_korean_question_keeps_korean_sentences_and_records_the_rest() -> None: + sentence = "독립 재집계에서도 같은 4건이 확인되면 이 후보를 제외한다." + fragments = ("完全な個別記録", "生成過程", "if both runs match, drop it") + settled = _settled( + _portfolio(_hypothesis("h1", (fragments[0], sentence, *fragments[1:]))), KOREAN_QUESTION + ) + assert settled.hypotheses[0].refutation_conditions == (sentence,) + dropped = settled.hypotheses[0].dropped + assert [(d.hypothesis_id, d.reason, d.text) for d in dropped] == [ + ("h1", MISMATCH, item) for item in fragments + ] + assert all(isinstance(d, DroppedExpectation) and d.test_id == "" for d in dropped) + + +def test_a_question_without_hangul_drops_nothing() -> None: + conditions = ("完全な個別記録", "if both runs match, drop it") + for problem in (ENGLISH_QUESTION, "", "検出基準が不合格です。"): + settled = _settled(_portfolio(_hypothesis("h1", conditions)), problem) + assert settled.hypotheses[0].refutation_conditions == conditions + assert settled.hypotheses[0].dropped == () + + +def test_the_sixth_live_output_loses_exactly_its_two_japanese_fragments() -> None: + # The refutation conditions of the 2026-10-09 v3 run on SYN-C-DET-RAIN (6th check), as stored. + first = ("完全な個別記録",) + second = ("生成過程",) + third, fourth, fifth = (THIRD,), (FOURTH,), FIFTH + portfolio = _portfolio( + _hypothesis("h1", first), + _hypothesis("h2", second), + _hypothesis("h3", third), + _hypothesis("h4", fourth), + _hypothesis("h5", fifth), + ) + settled = _settled(portfolio, KOREAN_QUESTION) + kept = [h.refutation_conditions for h in settled.hypotheses] + assert kept == [(), (), third, fourth, fifth] + dropped = [(d.hypothesis_id, d.text) for h in settled.hypotheses for d in h.dropped] + assert dropped == [("h1", first[0]), ("h2", second[0])] + + +SCRIPT = "REFUTATION_SCRIPT_MISMATCH" + + +def test_a_korean_question_drops_a_condition_with_chinese_characters_and_records_it() -> None: + clean = "독립 재집계에서도 같은 4건이 확인되면 이 후보를 제외한다." + mixed = "確認된 설정 후보를 바꿔도 오탐 변화가 없으면 해당 설정 후보를 제외한다." + nohangul = "if both runs match, drop it" + settled = _settled(_portfolio(_hypothesis("h1", (mixed, clean, nohangul))), KOREAN_QUESTION) + assert settled.hypotheses[0].refutation_conditions == (clean,) + assert [(d.reason, d.text) for d in settled.hypotheses[0].dropped] == [ + (MISMATCH, nohangul), + (SCRIPT, mixed), + ] + + +def test_the_script_rule_is_off_when_the_question_itself_has_chinese_characters_or_no_hangul() -> ( + None +): + mixed = "確認된 설정 후보를 바꿔도 오탐 변화가 없으면 해당 설정 후보를 제외한다." + for problem in (KOREAN_QUESTION + " 檢證 포함", ENGLISH_QUESTION, ""): + settled = _settled(_portfolio(_hypothesis("h1", (mixed,))), problem) + assert settled.hypotheses[0].refutation_conditions == (mixed,) + assert settled.hypotheses[0].dropped == () + + +def test_the_seventh_live_output_loses_its_three_chinese_character_sentences() -> None: + # The refutation conditions of the 2026-10-09 7th check that carried Chinese characters. + sentences = ( + "生成 당시 기록에서 다른 결과를 의도했고 작성 오류로 16/20이 되었음이 확인되면 " + "이 후보를 제외한다.", + "確認된 설정 후보를 바꿔도 충분한 정밀도에서 오탐 변화가 없으면 해당 설정 후보를 제외한다.", + "独立 대조에서 중복·제외 오류가 없고 유효 관찰 시간도 정확하면 집계 오류 후보를 제외한다.", + ) + fine = ("관련 대상·조건의 구성이 같고 충분한 비교에서도 차이가 없으면 이 후보를 제외한다.",) + portfolio = _portfolio( + _hypothesis("h1", sentences[:1]), + _hypothesis("h2", sentences[1:]), + _hypothesis("h3", fine), + ) + settled = _settled(portfolio, KOREAN_QUESTION) + assert [h.refutation_conditions for h in settled.hypotheses] == [(), (), fine] + dropped = [(d.hypothesis_id, d.reason, d.text) for h in settled.hypotheses for d in h.dropped] + assert dropped == [ + ("h1", SCRIPT, sentences[0]), + ("h2", SCRIPT, sentences[1]), + ("h2", SCRIPT, sentences[2]), + ] diff --git a/tests/unit/domain/test_visible_text_language_settle.py b/tests/unit/domain/test_visible_text_language_settle.py new file mode 100644 index 0000000..9ec6e38 --- /dev/null +++ b/tests/unit/domain/test_visible_text_language_settle.py @@ -0,0 +1,221 @@ +"""Foreign script in the readable text of a hypothesis: items are dropped, single texts flagged. + +For a Korean question the v3 settling step drops a list item (missing evidence, counterevidence +queries, predicted observations, assumptions) with no Hangul or with Chinese characters or kana the +question and the supplied evidence do not have, and records it. A single text (statement, +uncertainty, test text) is never removed: it stays and a FLAGGED record is made. Quoted characters +are not a problem, an English question changes nothing, and a v2 result is never touched. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest +from tests.unit.domain.test_refutation_language_settle import ( + ENGLISH_QUESTION, + KOREAN_QUESTION, + _hypothesis, + _portfolio, +) + +from thoth.application.services.hypothesis_contract_v3 import settle_contract +from thoth.application.services.text_script import KANA as KANA_LETTERS +from thoth.application.services.text_script import language_problem +from thoth.domain.hypothesis import HypothesisPortfolio, HypothesisPortfolioV3 + +GOOD = "시행별 정답과 탐지 기록" +KANA = "독립した 반복 관측 결과" +HANJA = "確認된 설정 이력" +ENGLISH = "per-trial ground truth" + + +@dataclass +class Span: # only what settling reads + span_id: str + exact_text: str + + +def _with(**changes: Any) -> dict[str, Any]: + item = _hypothesis("h1", ()) + item.update(changes) + return item + + +def _settled(item: dict[str, Any], problem: str, *spans: Span) -> HypothesisPortfolioV3: + portfolio = _portfolio(item, _hypothesis("h2", ())) + settled = settle_contract(portfolio, spans, problem=problem) # type: ignore[arg-type] + assert isinstance(settled, HypothesisPortfolioV3) + return settled + + +def _records(settled: HypothesisPortfolioV3) -> list[tuple[str, str, str, str | None]]: + return [(d.test_id, d.hypothesis_id, d.reason, d.text) for d in settled.hypotheses[0].dropped] + + +@pytest.mark.parametrize( + ("field", "reason"), + [ + ("missing_evidence", "MISSING_EVIDENCE"), + ("counterevidence_queries", "COUNTEREVIDENCE_QUERIES"), + ("predicted_observations", "PREDICTED_OBSERVATIONS"), + ("assumptions", "ASSUMPTIONS"), + ], +) +def test_a_list_item_with_kana_chinese_characters_or_no_hangul_is_dropped_and_recorded( + field: str, reason: str +) -> None: + settled = _settled(_with(**{field: (GOOD, KANA, HANJA, ENGLISH)}), KOREAN_QUESTION) + assert getattr(settled.hypotheses[0], field) == (GOOD,) + assert _records(settled) == [ + ("", "h1", f"{reason}_SCRIPT_MISMATCH", KANA), + ("", "h1", f"{reason}_SCRIPT_MISMATCH", HANJA), + ("", "h1", f"{reason}_LANGUAGE_MISMATCH", ENGLISH), + ] + + +def test_a_single_text_stays_and_is_flagged_with_its_test_when_it_has_one() -> None: + item = _with(statement=HANJA, uncertainty="まだ 확인되지 않음") + (test,) = item["discriminating_tests"] + test.update( + procedure_candidate="独立 재집계를 한다", + expected_if_true=GOOD, + expected_if_alternative=KANA, + ) + settled = _settled(item, KOREAN_QUESTION) + kept = settled.hypotheses[0] + assert (kept.statement, kept.uncertainty) == (HANJA, "まだ 확인되지 않음") + assert kept.discriminating_tests[0].procedure_candidate == "独立 재집계를 한다" + assert kept.discriminating_tests[0].expected_if_alternative == KANA + assert _records(settled) == [ + ("", "h1", "STATEMENT_SCRIPT_FLAGGED", HANJA), + ("", "h1", "UNCERTAINTY_SCRIPT_FLAGGED", "まだ 확인되지 않음"), + ("t1", "h1", "PROCEDURE_CANDIDATE_SCRIPT_FLAGGED", "独立 재집계를 한다"), + ("t1", "h1", "EXPECTED_IF_ALTERNATIVE_SCRIPT_FLAGGED", KANA), + ] + + +def test_a_single_text_with_no_hangul_is_flagged_for_its_language_and_kept() -> None: + settled = _settled(_with(statement="a cause"), KOREAN_QUESTION) + assert settled.hypotheses[0].statement == "a cause" + assert _records(settled) == [("", "h1", "STATEMENT_LANGUAGE_FLAGGED", "a cause")] + + +def test_characters_quoted_from_the_question_or_the_evidence_are_not_a_problem() -> None: + quoted = "기록의 檢證 열을 본다" + quoted_kana = "원문 'まだ' 표기를 본다" + item = _with(missing_evidence=(quoted, quoted_kana), statement=quoted) + unquoted = _settled(item, KOREAN_QUESTION) + assert unquoted.hypotheses[0].missing_evidence == () + evidence = (Span("span:a", "자료 헤더: 檢證, まだ"),) + settled = _settled(item, KOREAN_QUESTION, *evidence) + assert settled.hypotheses[0].missing_evidence == (quoted, quoted_kana) + assert _records(settled) == [] + in_question = _settled(item, KOREAN_QUESTION + " 檢證 まだ") + assert in_question.hypotheses[0].missing_evidence == (quoted, quoted_kana) + assert _records(in_question) == [] + + +def test_the_only_missing_evidence_a_hypothesis_has_is_flagged_not_dropped() -> None: + # a hypothesis with no support refs needs one gap; removing it would make the record invalid + item = _with(support_evidence_refs=(), missing_evidence=(KANA,)) + settled = _settled(item, KOREAN_QUESTION) + assert settled.hypotheses[0].missing_evidence == (KANA,) + assert _records(settled) == [("", "h1", "MISSING_EVIDENCE_SCRIPT_FLAGGED", KANA)] + with_support = _settled(_with(missing_evidence=(KANA,)), KOREAN_QUESTION) + assert with_support.hypotheses[0].missing_evidence == () + + +def test_the_only_counterevidence_query_is_flagged_when_nothing_else_stands_for_it() -> None: + # the portfolio check rejects a hypothesis with neither a counterevidence ref nor a query + alone = _settled(_with(counterevidence_queries=(KANA,)), KOREAN_QUESTION) + assert alone.hypotheses[0].counterevidence_queries == (KANA,) + assert _records(alone) == [("", "h1", "COUNTEREVIDENCE_QUERIES_SCRIPT_FLAGGED", KANA)] + backed = _with(counterevidence_queries=(KANA,), counterevidence_refs=("span:a",)) + dropped = _settled(backed, KOREAN_QUESTION) + assert dropped.hypotheses[0].counterevidence_queries == () + assert _records(dropped) == [("", "h1", "COUNTEREVIDENCE_QUERIES_SCRIPT_MISMATCH", KANA)] + + +def test_an_english_question_changes_nothing() -> None: + item = _with( + missing_evidence=(KANA, ENGLISH), statement=HANJA, assumptions=(HANJA,), uncertainty=KANA + ) + for problem in (ENGLISH_QUESTION, ""): + settled = _settled(item, problem) + assert settled.hypotheses[0].missing_evidence == (KANA, ENGLISH) + assert settled.hypotheses[0].dropped == () + + +def test_a_v2_result_is_never_touched() -> None: + dumped = _portfolio(_with(missing_evidence=(KANA,)), _hypothesis("h2", ())).model_dump() + for item in dumped["hypotheses"]: + item.pop("refutation_conditions") + for test in item["discriminating_tests"]: + test.pop("expected_by_hypothesis") + plain = HypothesisPortfolio.model_validate(dumped) + assert not isinstance(plain, HypothesisPortfolioV3) + assert settle_contract(plain, (), problem=KOREAN_QUESTION) is plain + + +DOT = "\u30fb" # the katakana middle dot: punctuation, not a kana letter +KOREAN_DOT = "\u00b7" + + +def test_the_katakana_middle_dot_is_punctuation_and_a_kana_letter_is_not() -> None: + assert KANA_LETTERS.search(DOT) is None and KANA_LETTERS.search("ー") is not None + assert KANA_LETTERS.search("した") is not None and KANA_LETTERS.search("また") is not None + assert language_problem(f"조건{DOT}측정계", KOREAN_QUESTION) is None + assert language_problem("독립した 반복", KOREAN_QUESTION) == "SCRIPT" + assert language_problem(f"독립した{DOT}반복", KOREAN_QUESTION) == "SCRIPT" + + +def test_a_middle_dot_in_any_readable_text_becomes_the_korean_one_and_nothing_is_dropped() -> None: + dotted = f"조건{DOT}측정계{DOT}계산식을 검증한다" + fixed = f"조건{KOREAN_DOT}측정계{KOREAN_DOT}계산식을 검증한다" + item = _with( + statement=dotted, + uncertainty=dotted, + missing_evidence=(dotted, GOOD), + counterevidence_queries=(dotted,), + predicted_observations=(dotted,), + assumptions=(dotted,), + refutation_conditions=(dotted,), + ) + (test,) = item["discriminating_tests"] + test.update(procedure_candidate=dotted, expected_if_true=dotted, expected_if_alternative=dotted) + test["expected_by_hypothesis"] = ({"hypothesis_id": "h1", "expected": dotted, "basis": ()},) + settled = _settled(item, KOREAN_QUESTION) + kept = settled.hypotheses[0] + assert (kept.statement, kept.uncertainty) == (fixed, fixed) + assert kept.missing_evidence == (fixed, GOOD) + assert kept.counterevidence_queries == kept.predicted_observations == (fixed,) + assert kept.assumptions == kept.refutation_conditions == (fixed,) + shown = kept.discriminating_tests[0] + assert (shown.procedure_candidate, shown.expected_if_true) == (fixed, fixed) + assert shown.expected_if_alternative == fixed + assert [row.expected for row in shown.expected_by_hypothesis] == [fixed] + assert _records(settled) == [] # nothing was dropped or flagged, and the change is not recorded + + +def test_a_real_kana_next_to_a_middle_dot_is_still_caught() -> None: + settled = _settled(_with(missing_evidence=(f"독립した{DOT}반복", GOOD)), KOREAN_QUESTION) + assert settled.hypotheses[0].missing_evidence == (GOOD,) + assert [reason for _, _, reason, _ in _records(settled)] == ["MISSING_EVIDENCE_SCRIPT_MISMATCH"] + + +def test_a_middle_dot_the_question_or_the_evidence_has_is_left_as_it_is() -> None: + item = _with(missing_evidence=(f"원문의 {DOT} 표기를 본다",)) + quoted = _settled(item, KOREAN_QUESTION, Span("span:a", f"헤더: 가{DOT}나")) + assert quoted.hypotheses[0].missing_evidence == (f"원문의 {DOT} 표기를 본다",) + asked = _settled(item, KOREAN_QUESTION + f" {DOT}") + assert asked.hypotheses[0].missing_evidence == (f"원문의 {DOT} 표기를 본다",) + + +def test_an_english_question_keeps_the_middle_dot() -> None: + item = _with(statement=f"a{DOT}b", missing_evidence=(f"c{DOT}d",)) + for problem in (ENGLISH_QUESTION, ""): + settled = _settled(item, problem) + assert settled.hypotheses[0].statement == f"a{DOT}b" + assert settled.hypotheses[0].missing_evidence == (f"c{DOT}d",) diff --git a/tests/unit/models/test_hypothesis_contract_wire_text.py b/tests/unit/models/test_hypothesis_contract_wire_text.py index b8568c4..d1786c7 100644 --- a/tests/unit/models/test_hypothesis_contract_wire_text.py +++ b/tests/unit/models/test_hypothesis_contract_wire_text.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re from datetime import UTC, datetime from thoth.adapters.models import strict_output_schema @@ -59,7 +60,7 @@ def _request( def test_the_v3_text_is_added_only_for_the_v3_prompt_version() -> None: - v3 = prompt_envelope(_request("hypothesis_portfolio.v3", HypothesisPortfolioV3)) + v3 = prompt_envelope(_request("hypothesis_portfolio.v3.3", HypothesisPortfolioV3)) v2 = prompt_envelope(_request("hypothesis_portfolio.v2", HypothesisPortfolio)) assert "CONTRACT_V3" in v3 and "expected_by_hypothesis" in v3 and "refutation_conditions" in v3 assert "CONTRACT_V3" not in v2 and "expected_by_hypothesis" not in v2 @@ -71,11 +72,192 @@ def test_the_contract_without_a_version_is_the_text_v2_always_had() -> None: plain = role_contract("HYPOTHESIS_GENERATOR") assert role_contract("HYPOTHESIS_GENERATOR", "hypothesis_portfolio.v2") == plain assert "CONTRACT_V3" not in plain - added = hypothesis_contract_addendum("hypothesis_portfolio.v3") - assert added and added in role_contract("HYPOTHESIS_GENERATOR", "hypothesis_portfolio.v3") + added = hypothesis_contract_addendum("hypothesis_portfolio.v3.3") + assert added and added in role_contract("HYPOTHESIS_GENERATOR", "hypothesis_portfolio.v3.3") + + +def test_the_v3_text_asks_for_predictions_without_evidence() -> None: + text = hypothesis_contract_addendum("hypothesis_portfolio.v3.3") + # A prediction follows from the hypothesis itself, so it is written without evidence. + assert "if that hypothesis were true" in text + assert "even when no supplied evidence supports it" in text + # Evidence is named only where it supports the prediction, else the list is empty. + assert "only when they support that prediction" in text and "empty list" in text + # Every test gets a row for every hypothesis, whichever one the test was designed for. + assert "whichever hypothesis the test was designed for" in text + assert "never leave a hypothesis out" in text and "or leave the hypothesis out" not in text + # A hypothesis the test was not designed for says what follows if it were true, + # consistently with expected_if_alternative. + assert "was not designed for" in text and "expected_if_alternative" in text + # 모름 only when the result cannot be fixed logically; another hypothesis's test is no reason. + assert "only when, even if that hypothesis were true, the result" in text + assert "cannot be fixed logically" in text + assert "never a reason for 모름" in text + assert "implies nothing about this test" not in text and "gives no basis" not in text + # Hypotheses that predict the same result share one label. + assert "same label for both" in text + # The rules that stay: same wording, short label, no score, no re-judging. + assert "worded the same for the same result" in text and "80 characters" in text + assert "Add no probability, score or ranking" in text + assert "do not re-judge or change any verdict" in text + assert "up to five short sentences" in text + + +BEFORE_LENGTH = 1708 # the v3 text before the pair goal and the example were added + + +def test_the_v3_text_asks_for_pairs_to_be_told_apart_and_shows_one_other_domain_example() -> None: + text = hypothesis_contract_addendum("hypothesis_portfolio.v3.3") + # The goal: every two hypotheses differ on at least one test, without forcing it. + assert "every two hypotheses of this portfolio" in text + assert "do not force it" in text and "does not separate them" in text + # One worked example from another domain, marked as form only and not to be copied. + assert "Illustration of the form only" in text and "another domain" in text + assert "never copy its wording" in text + assert "cache" in text and "lock" in text and "network" in text + # The question-language rule comes right after the example, whatever language the example is in. + example_end = text.index("fixes the result of every test") + language = text.index("in the language of the question") + assert 0 < language - example_end < 120 + # The example adds a bounded amount of text (about 1,000; the later keep-every-hypothesis + # sentence adds up to 400, the four-hypothesis split example up to 600, the one-cause and + # fixed-condition sentences up to 900 and the alternatives sentence up to 600, each checked in + # its own test) and no word of the demo data. + assert len(text) - BEFORE_LENGTH <= 3_500 + lowered = text.lower() + for word in ("radar", "synthetic", "syn-", "탐지", "레이더"): + assert word not in lowered + assert re.search(r"\brain\b", lowered) is None + # Nothing is added for v2. + assert hypothesis_contract_addendum("hypothesis_portfolio.v2") == "" + + +BEFORE_V3G_LENGTH = 2694 # the v3 text before the keep-every-hypothesis sentence was added + + +def test_the_pair_goal_is_about_test_choice_and_never_cuts_hypotheses() -> None: + text = hypothesis_contract_addendum("hypothesis_portfolio.v3.3") + # The goal is about which tests to choose, not which hypotheses to keep. + assert "about which tests to choose, not about which hypotheses to keep" in text + # Every hypothesis that would have been proposed without this contract stays. + assert "without this contract" in text + assert "never reduce their number" in text and "alternatives_considered" in text + # Two hypotheses that no test can separate are both kept and share a label. + assert "keep both and give them the same label" in text + # The sentence follows the pair goal directly and adds a bounded amount of text. + assert text.index("does not separate them") < text.index("about which tests to choose") + # This sentence adds about 340; the split example added later adds up to 600 more and the + # one-cause and fixed-condition sentences up to 900 and the alternatives sentence up to 600 + # (their own tests bound them), so everything since this sentence was written stays within + # 2,500. + assert len(text) - BEFORE_V3G_LENGTH <= 2_500 + # Nothing is added for v2. + assert hypothesis_contract_addendum("hypothesis_portfolio.v2") == "" + + +BEFORE_P1_LENGTH = 3036 # the v3 text before the example became four hypotheses and the split goal + + +def test_the_example_shows_a_two_and_two_split_and_the_goal_asks_for_one() -> None: + text = hypothesis_contract_addendum("hypothesis_portfolio.v3.3") + # The goal: besides confirming single hypotheses, one test that splits them into like-sized + # groups, when it can be justified. + assert "at least one test that splits the hypotheses into groups of similar size" in text + assert "whichever result comes" in text and "do not force one" in text + # The example has four hypotheses (another domain), a test that splits two and two, and a + # test for the fourth, so that together every pair is separated (the goal it teaches). + assert "H4 disk degradation" in text and "H5" not in text + assert "splits two and two" in text + assert "H3 becomes fast; H4 becomes fast" in text + assert "Test for H4 (repeat with a fresh local disk)" in text + assert "Together every pair is separated" in text + assert "share every label" not in text + # Why the splitting test is worth it is a reason only; it gives no order (the screen's rule + # computes the order) and does not clash with the ban on ranking. + assert "at most two candidates whichever way it goes" in text + assert "narrows the candidates more" in text and "do it first" not in text + # No ranking words were added: the order is the screen's rule, not the model's. + assert "Add no probability, score or ranking" in text + # The change is bounded and nothing is added for v2. + assert len(text) - BEFORE_P1_LENGTH <= 2_100 # this change 600, the next 900, the last 600 + assert hypothesis_contract_addendum("hypothesis_portfolio.v2") == "" + + +BEFORE_C2_LENGTH = 3532 # the v3 text before the one-cause and fixed-condition sentences + + +def test_each_column_assumes_its_own_hypothesis_and_the_tests_hold_open_conditions_fixed() -> None: + text = hypothesis_contract_addendum("hypothesis_portfolio.v3.3") + # One cause per column: the other hypotheses are not the explanation, so a second cause that + # could be present is no reason for 모름. + assert "Each expected assumes that hypothesis is the explanation" in text + assert "the other hypotheses of this portfolio are not" in text + assert "write the result that follows under that assumption" in text + # A test that leaves a condition open is replaced by one that fixes or also measures it, and + # a test that changes a setting says on which inputs it runs and what else is measured. + assert "a condition the test leaves open" in text + assert "holds that condition fixed or measures it as well" in text + assert "on which inputs it is run and what is measured alongside" in text + # Both sit around the existing 모름 sentence without contradicting it: the assumption comes + # first, the 모름 rule is unchanged, and the test choice follows it. + assumption = text.index("Each expected assumes that hypothesis is the explanation") + unknown = text.index("only when, even if that hypothesis were true, the result") + never = text.index("never a reason for 모름") + choice = text.index("holds that condition fixed or measures it as well") + assert assumption < unknown < never < choice < text.index("Choose the tests so that every two") + assert "cannot be fixed logically" in text + # The language rule bans kana as well as Chinese characters. + assert "no Chinese characters (hanja) or kana" in text + # No new example, no word of the demo data, and a bounded amount of text. + assert text.count("Illustration of the form only") == 1 + lowered = text.lower() + for word in ("radar", "synthetic", "syn-", "탐지", "레이더", "detection", "fog"): + assert word not in lowered + assert re.search(r"\brain\b", lowered) is None + assert len(text) - BEFORE_C2_LENGTH <= 1_500 # these sentences up to 900, the next up to 600 + assert hypothesis_contract_addendum("hypothesis_portfolio.v2") == "" def test_the_strict_schema_of_the_v3_form_asks_for_the_new_fields() -> None: schema = str(strict_output_schema(HypothesisPortfolioV3)) assert "expected_by_hypothesis" in schema and "refutation_conditions" in schema assert "expected_by_hypothesis" not in str(strict_output_schema(HypothesisPortfolio)) + + +BEFORE_D1_LENGTH = 4098 # the v3 text before the alternatives sentence + + +def test_the_hypotheses_are_alternatives_and_a_path_is_written_as_its_own_cause() -> None: + text = hypothesis_contract_addendum("hypothesis_portfolio.v3.3") + # The portfolio is a set of alternatives: one explanation excludes the others. + assert "The hypotheses of this portfolio are alternatives to one another" in text + assert "if one is the explanation, the others are not" in text + # A hypothesis that is only a path or stage another cause could take is rewritten as a cause + # of its own, so that the two are real alternatives; it is rewritten, never dropped or merged. + assert "a path or stage through which another hypothesis's cause could act" in text + assert "a cause that arises on its own" in text + assert "never drop or merge it" in text + # It follows the keep-every-hypothesis sentences and comes before the split-test goal, so it + # does not read as permission to cut or merge hypotheses. + keep = text.index("never reduce their number") + same = text.index("keep both and give them the same label") + alternatives = text.index("are alternatives to one another") + assert keep < same < alternatives < text.index("Besides tests that confirm a single hypothesis") + # No new example, no word of the demo data, and a bounded amount of text. + assert text.count("Illustration of the form only") == 1 + lowered = text.lower() + for word in ( + "radar", + "synthetic", + "syn-", + "탐지", + "레이더", + "detection", + "fog", + "track", + "weather", + ): + assert word not in lowered + assert re.search(r"\brain\b", lowered) is None + assert len(text) - BEFORE_D1_LENGTH <= 600 + assert hypothesis_contract_addendum("hypothesis_portfolio.v2") == "" diff --git a/tests/unit/models/test_hypothesis_generator_opening.py b/tests/unit/models/test_hypothesis_generator_opening.py new file mode 100644 index 0000000..e95e119 --- /dev/null +++ b/tests/unit/models/test_hypothesis_generator_opening.py @@ -0,0 +1,53 @@ +"""The v3 generator contract calls a hypothesis a candidate to check, not a claim to justify. + +Only the v3 prompt version changes the two opening sentences of the generator's base contract; +the v2 text and the text without a version stay as they always were. +""" + +from __future__ import annotations + +from thoth.adapters.models.codex_oauth import role_contract + +V2_OPENING = ( + "Create only justified source-grounded hypotheses. Zero or one is allowed when " + "alternatives_considered, next_checks and uncertainty_reserve explain the limitation. " + "Causal locus may be null for predictive or exploratory intent. Drafts may have gaps. " + "Every hypothesis needs support refs or explicit missing evidence, a " + "counterevidence query, predicted observations, and a discriminating test. Use only " + "provided span IDs and copy input_head_set_digest exactly. " +) +V3 = "hypothesis_portfolio.v3.3" +V2 = "hypothesis_portfolio.v2" + + +def test_the_v2_and_unversioned_generator_text_keep_every_character_of_the_old_opening() -> None: + for version in ("", V2, "something_else"): + assert role_contract("HYPOTHESIS_GENERATOR", version).startswith(V2_OPENING) + + +def test_the_v3_opening_asks_for_candidates_and_never_calls_missing_evidence_a_reason_to_drop() -> ( + None +): + text = role_contract("HYPOTHESIS_GENERATOR", V3) + # The old exit is gone from the v3 opening. + assert "Create only justified source-grounded hypotheses" not in text + assert "Zero or one is allowed when" not in text + # A hypothesis is a candidate to check; thin evidence still gets a DRAFT, gaps go in + # missing_evidence, and a lack of evidence is never a reason to leave a candidate out. + assert "a candidate to examine, not a claim" in text + assert "DRAFT hypothesis" in text and "usually three to five" in text + assert "missing_evidence" in text + assert "never a reason to leave a hypothesis out" in text + # Few hypotheses stay possible when there really are few; alternatives are for non-candidates. + assert "only when at most one plausible candidate exists" in text + assert "only candidates that make no sense, fall outside the question, or duplicate" in text + # The rules that stay are still there. + assert "Every hypothesis needs support refs or explicit missing evidence" in text + assert "copy input_head_set_digest exactly" in text + + +def test_the_hanja_ban_is_part_of_the_v3_contract_only() -> None: + v3 = role_contract("HYPOTHESIS_GENERATOR", V3) + assert "Hangul" in v3 and "Chinese characters" in v3 and "hanja" in v3 + for version in ("", V2): + assert "hanja" not in role_contract("HYPOTHESIS_GENERATOR", version) diff --git a/tests/unit/models/test_hypothesis_table_filler_wire_text.py b/tests/unit/models/test_hypothesis_table_filler_wire_text.py new file mode 100644 index 0000000..2b95924 --- /dev/null +++ b/tests/unit/models/test_hypothesis_table_filler_wire_text.py @@ -0,0 +1,114 @@ +"""The table-filler role reaches every adapter through the shared role contract (no model call). + +The Codex, Claude and xAI adapters build their prompt with `prompt_envelope` and the +OpenAI-compatible adapter calls `role_contract`, so checking those two covers them. The +generator's contract text is not touched by the new role. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from thoth.adapters.models import strict_output_schema +from thoth.adapters.models.codex_oauth import prompt_envelope, role_contract +from thoth.adapters.models.hypothesis_contract_text import ( + hypothesis_contract_addendum, + table_filler_contract, +) +from thoth.domain.enums import ModelRole +from thoth.domain.hypothesis import HypothesisTableFill +from thoth.domain.model import ContextPack, ModelRequest + +VERSION = "hypothesis_table_filler.v4" + + +def _request() -> ModelRequest[HypothesisTableFill]: + return ModelRequest( + role=ModelRole.HYPOTHESIS_TABLE_FILLER, + project_id="p", + cutoff_at=datetime(2026, 10, 10, tzinfo=UTC), + context_pack=ContextPack( + case_id="c", + project_id="p", + object_id="o", + problem="무엇이 원인입니까?", + evidence=(), + criteria=(), + sufficiency=None, + input_head_set_digest="b" * 64, + research_context={"table_filler": {"hypotheses": [], "tests": []}}, + ), + output_model=HypothesisTableFill, + prompt_version=VERSION, + model_policy_ref="policy:1", + max_output_tokens=3_000, + ) + + +def test_the_role_has_its_own_contract_and_the_envelope_carries_it() -> None: + text = role_contract("HYPOTHESIS_TABLE_FILLER", VERSION) + assert table_filler_contract() in text + envelope = prompt_envelope(_request()) + assert "HYPOTHESIS_TABLE_FILLER" in envelope and table_filler_contract() in envelope + assert "table_filler" in envelope and VERSION in envelope + # the generator's text is neither added to this role nor changed by it + assert "CONTRACT_V3" not in envelope + assert hypothesis_contract_addendum("hypothesis_portfolio.v3.3") not in text + assert role_contract("HYPOTHESIS_GENERATOR") == role_contract( + "HYPOTHESIS_GENERATOR", "hypothesis_portfolio.v2" + ) + + +def test_the_contract_asks_for_cells_only_with_the_lessons_of_the_earlier_checks() -> None: + text = table_filler_contract() + # only the open cells, nothing new, nothing rewritten + assert "open_hypothesis_ids" in text and "Add no test and no hypothesis" in text + assert "change no sentence or existing cell" in text + # a prediction follows from the hypothesis; it is not a claim about the data + assert "if that hypothesis were true" in text + assert "even when no supplied evidence supports it" in text + assert "is not a claim about the data" in text + # a hypothesis the test was not designed for: consistent with expected_if_alternative + assert "was not designed for" in text and "expected_if_alternative" in text + # 모름 only when the result cannot be fixed; being another hypothesis's test is never a reason + assert "only when, even if that hypothesis were true" in text + assert "never a reason for 모름" in text + # a 모름 comes with one sentence saying what leaves the result undetermined + assert "also write unknown_reason: one sentence" in text + assert "left undetermined even if that hypothesis were true" in text + assert "use null for unknown_reason with any other answer" in text + # the same result keeps the label already used in the table + assert "reuse that label exactly" in text and "never reword it" in text + # short, in the question's language, no score, no re-judging, only known ids + assert "80 characters" in text and "Hangul only" in text and "hanja" in text + assert "Add no probability, score or ranking" in text + assert "do not re-judge or change any verdict" in text + assert "Use only hypothesis_id and test_id values that appear in table_filler" in text + # the illustration is marked as form only and uses another domain + assert "Illustration of the form only" in text and "never copy its wording" in text + + +def test_the_contract_has_each_row_assume_its_own_hypothesis_and_may_fill_a_designed_for_row() -> ( + None +): + text = table_filler_contract() + # one cause per row: the other hypotheses are not the explanation, whatever else could exist + assert "Each row assumes that hypothesis is the explanation" in text + assert "the other hypotheses of this portfolio are not" in text + assert "write the result that follows under that assumption" in text + assert "even if another cause could also be present" in text + # the assumption comes before the 모름 rule, which keeps its wording + assert text.index("Each row assumes") < text.index("Write expected as 모름 only when") + assert "only when, even if that hypothesis were true" in text + # a designed-for row that is open is the result the test predicts for its own hypothesis + assert "If the designed hypothesis itself is open" in text and "expected_if_true" in text + # no new example + assert text.count("Illustration of the form only") == 1 + + +def test_the_form_is_cells_only_and_has_no_generator_field() -> None: + schema = str(strict_output_schema(HypothesisTableFill)) + for name in ("designed_for", "test_id", "rows", "hypothesis_id", "expected", "basis"): + assert name in schema + for name in ("refutation_conditions", "expected_by_hypothesis", "statement", "portfolio_id"): + assert name not in schema diff --git a/tests/unit/models/test_transport_diagnostic.py b/tests/unit/models/test_transport_diagnostic.py index b3202bf..773df25 100644 --- a/tests/unit/models/test_transport_diagnostic.py +++ b/tests/unit/models/test_transport_diagnostic.py @@ -130,7 +130,10 @@ def __str__(self) -> str: oversized = OSError(2**40, "unused") error.__cause__ = oversized assert exception_detail(error)["nested_errno"] is None - assert exception_detail(httpx.ReadError("unused")) == {"httpx_error_type": "ReadError"} + assert exception_detail(httpx.ReadError("unused")) == { + "httpx_error_type": "ReadError", + "error_message_prefix": "[REDACTED]", + } @pytest.mark.parametrize( @@ -182,7 +185,7 @@ def test_framing_and_unknown_version(): async def test_collection_failure_does_not_replace_primary(monkeypatch: pytest.MonkeyPatch): - def broken(error: httpx.HTTPError) -> dict[str, object]: + def broken(error: httpx.HTTPError, sensitive_values: tuple[str, ...] = ()) -> dict[str, object]: raise ValueError("diagnostic helper fault") monkeypatch.setattr(receive_stats, "exception_detail", broken) diff --git a/tests/unit/models/test_transport_diagnostic_privacy.py b/tests/unit/models/test_transport_diagnostic_privacy.py new file mode 100644 index 0000000..a6dfd70 --- /dev/null +++ b/tests/unit/models/test_transport_diagnostic_privacy.py @@ -0,0 +1,175 @@ +"""Correlation IDs and error excerpts are useful only after bounded privacy filtering.""" + +import json +from collections.abc import AsyncIterator + +import httpx +import pytest + +from thoth.adapters.models.codex_http import CodexHttpExecutor +from thoth.adapters.models.transport_diagnostic import exception_detail, response_detail +from thoth.adapters.models.transport_message import safe_exception_prefix +from thoth.domain.model_dispatch import OAuthSession, TransportDiagnostic +from thoth.ports.model import ModelTransportHold + +MESSAGE = "peer closed connection without sending complete message body (incomplete chunked read)" +REQUEST_ID = "b5c60fb9-2ee6-4ca4-b44d-30b7234660ad" +RAY_ID = "abcdef0123456789-ICN" + + +@pytest.mark.parametrize( + "suffix", + [ + " Authorization: Bearer private-token", + " Proxy-Authorization: Basic cHJpdmF0ZQ==", + " Cookie: session=private-cookie; other=private-other", + " Set-Cookie: session=private-cookie\r\nprivate-next-line", + ' {"access_token":"private-token", "refresh_token":"private-refresh"}', + " access-token=private-token", + " api_key: private-key", + " password=private-password", + " https://private-user:private-password@example.invalid/?key=private-query", + " PRIVATE_BARE_TOKEN_12345", + " sk-proj-privateapikey123456789", + " eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwcml2YXRlIn0.privateSignature", + " connection", # A known credential can be an otherwise allowed word. + ], +) +def test_error_prefix_masks_secret_context_and_unlabelled_credentials(suffix: str) -> None: + text = safe_exception_prefix(httpx.RemoteProtocolError(MESSAGE + suffix), ("connection",)) + assert text is not None and text.startswith("peer closed [REDACTED]") + for secret in ("private", "PRIVATE", "cHJpdmF0ZQ", "eyJ", "connection"): + assert secret not in text + assert "incomplete chunked read" in text + assert len(text) <= 256 and text.isascii() and "\n" not in text and "\r" not in text + + +def test_normal_h11_message_is_retained_but_excerpts_are_bounded() -> None: + assert safe_exception_prefix(httpx.RemoteProtocolError(MESSAGE), ()) == MESSAGE + assert len(safe_exception_prefix(httpx.ReadError("peer " * 1000), ()) or "") == 256 + # Redact before output truncation, including a credential across that boundary. + text = safe_exception_prefix(httpx.ReadError("peer " * 48 + "a-secret-crossing-boundary"), ()) + assert text is not None and "secret" not in text and "boundary" not in text + + +def test_message_formatting_failure_does_not_destroy_exception_class() -> None: + class Unprintable(httpx.ReadError): + def __str__(self) -> str: + raise ValueError("unavailable") + + error = Unprintable("unused") + assert safe_exception_prefix(error, ()) is None + assert exception_detail(error) == {"httpx_error_type": "UNKNOWN"} + + +@pytest.mark.parametrize( + "request_id,ray_id", + [ + ("bad value", "invalid"), + ("a" * 129, "a" * 21), + ("bad\r\nAuthorization: secret", "abcdef0123456789-ICN\n"), + ("Bearer-secret", "abcdef0123456789-ICN-secret"), + ("sk-secret", "abcdef0123456789-icn"), + ], +) +def test_invalid_header_values_are_omitted(request_id: str, ray_id: str) -> None: + result = response_detail( + httpx.Response(200, headers={"x-oai-request-id": request_id, "cf-ray": ray_id}) + ) + assert "x_oai_request_id" not in result and "cf_ray" not in result + + +def test_only_explicit_valid_headers_are_kept_and_known_secrets_are_rejected() -> None: + response = httpx.Response( + 200, + headers={ + "X-OAI-Request-ID": REQUEST_ID, + "CF-Ray": RAY_ID, + "Authorization": "private-token", + "Set-Cookie": "private-cookie", + "Server": "private-server", + }, + ) + result = response_detail(response) + assert result["x_oai_request_id"] == REQUEST_ID and result["cf_ray"] == RAY_ID + assert "private" not in json.dumps(result) + result = response_detail(response, (REQUEST_ID, RAY_ID)) + assert "x_oai_request_id" not in result and "cf_ray" not in result + result = response_detail(httpx.Response(200)) + assert "x_oai_request_id" not in result and "cf_ray" not in result + + +def test_optional_diagnostic_fields_decode_legacy_records_and_enforce_bounds() -> None: + legacy = TransportDiagnostic.model_validate({"httpx_error_type": "RemoteProtocolError"}) + assert ( + legacy.x_oai_request_id is None + and legacy.cf_ray is None + and legacy.error_message_prefix is None + ) + for field, value in ( + ("error_message_prefix", "x" * 257), + ("x_oai_request_id", "x" * 129), + ("cf_ray", "bad"), + ): + with pytest.raises(ValueError): + TransportDiagnostic.model_validate({field: value}) + + +@pytest.mark.parametrize("fail", [True, False]) +async def test_codex_created_then_disconnect_records_ids_without_retry_or_token_leak( + fail: bool, +) -> None: + class Session: + def read(self) -> OAuthSession: + return OAuthSession("connection", "peer", "diagnostic-fixture", "high") + + class Stream(httpx.AsyncByteStream): + closed = 0 + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield b'data: {"type":"response.created","response":{"id":"response-fixture"}}\n\n' + if fail: + raise httpx.RemoteProtocolError(MESSAGE + " Cookie: private-cookie") + yield ( + b'data: {"type":"response.completed",' + b'"response":{"id":"response-fixture","output":[]}}\n\n' + ) + + async def aclose(self) -> None: + self.closed += 1 + + stream = Stream() + calls: list[httpx.Request] = [] + + def serve(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response( + 200, stream=stream, headers={"x-oai-request-id": REQUEST_ID, "cf-ray": RAY_ID} + ) + + executor = CodexHttpExecutor(Session(), transport=httpx.MockTransport(serve)) + prepared = executor.prepare( + "fixture", {"type": "object"}, output_tokens=6000, timeout_seconds=None + ) + if fail: + with pytest.raises(ModelTransportHold, match="OAUTH_TRANSPORT_FAILURE") as caught: + await executor.dispatch(prepared) + observation = caught.value.observation + else: + observation = (await executor.dispatch(prepared)).observation + assert observation is not None and observation.transport_diagnostic is not None + diagnostic = observation.transport_diagnostic + assert diagnostic.x_oai_request_id == REQUEST_ID and diagnostic.cf_ray == RAY_ID + assert observation.visible_output_bytes == 0 and observation.transport_closed + assert len(calls) == 1 and stream.closed == 1 + if fail: + assert diagnostic.httpx_error_type == "RemoteProtocolError" + assert diagnostic.error_message_prefix is not None + assert "incomplete chunked read" in diagnostic.error_message_prefix + assert ( + "peer" not in diagnostic.error_message_prefix + and "connection" not in diagnostic.error_message_prefix + ) + assert "private-cookie" not in observation.model_dump_json() + else: + assert diagnostic.error_message_prefix is None and diagnostic.httpx_error_type is None diff --git a/tests/unit/models/test_user_visible_language_contract.py b/tests/unit/models/test_user_visible_language_contract.py new file mode 100644 index 0000000..34d05f4 --- /dev/null +++ b/tests/unit/models/test_user_visible_language_contract.py @@ -0,0 +1,36 @@ +"""Every role with the shared language rule is told to avoid foreign script (no model call).""" + +from __future__ import annotations + +from thoth.adapters.models.codex_oauth import role_contract +from thoth.domain.enums import ModelRole + +HANGUL_RULE = "for a Korean question write them in Hangul" +SCRIPT_RULE = "do not use Chinese characters or kana that the question or the quoted evidence" +KEPT_RULE = "Do not translate identifiers, quoted evidence, or numeric/table citations." + + +def test_every_role_with_the_shared_language_rule_is_told_to_avoid_foreign_script() -> None: + covered = [role for role in ModelRole if role != ModelRole.RESEARCH_PLANNER] + assert len(covered) >= 6 + for role in covered: + text = role_contract(role.value) + assert HANGUL_RULE in text, role + assert SCRIPT_RULE in text, role + assert KEPT_RULE in text, role # identifiers, quotes and citations stay untranslated + + +def test_the_generator_and_filler_get_it_in_every_prompt_version() -> None: + for version in ("hypothesis_portfolio.v2", "hypothesis_portfolio.v3.3"): + text = role_contract("HYPOTHESIS_GENERATOR", version) + assert HANGUL_RULE in text and SCRIPT_RULE in text + filler = role_contract("HYPOTHESIS_TABLE_FILLER", "hypothesis_table_filler.v4") + assert HANGUL_RULE in filler and SCRIPT_RULE in filler + + +def test_the_rule_is_one_sentence_per_role_and_names_no_data() -> None: + text = role_contract("SEMANTIC_REVIEWER") + assert text.count(HANGUL_RULE) == 1 + lowered = text.lower() + for word in ("radar", "synthetic", "syn-", "탐지", "detection", "rain"): + assert word not in lowered diff --git a/tests/unit/services/acquisition_coordinator_support.py b/tests/unit/services/acquisition_coordinator_support.py new file mode 100644 index 0000000..fab0199 --- /dev/null +++ b/tests/unit/services/acquisition_coordinator_support.py @@ -0,0 +1,395 @@ +"""Fast orchestration doubles with real typed records; SQLite rollback is tested separately.""" + +from __future__ import annotations + +import asyncio +import hashlib +from collections.abc import Callable +from datetime import UTC, datetime +from typing import cast +from unittest.mock import AsyncMock, Mock + +from thoth.application.services.acquisition_coordinator import AcquisitionCoordinator +from thoth.application.services.connector_service import ConnectorAcquisition, ConnectorService +from thoth.application.services.evidence_graph_service import EvidenceGraphService +from thoth.application.services.investigation_service import InvestigationService +from thoth.domain.acquisition import AcquisitionLead, EvidenceAtomicCommit, SearchIntent +from thoth.domain.artifact import ArtifactEnvelope, StructuralDocument +from thoth.domain.connectors import ( + ConnectorAccessRequest, + ConnectorOperation, + ConnectorReceipt, + ConnectorRunRecord, + NativeVersion, + NativeVersionKind, +) +from thoth.domain.enums import ( + AuthorityState, + CutoffState, + DimensionStatus, + SecurityClass, + SufficiencyStatus, +) +from thoth.domain.evidence import ( + EvidenceSpan, + InformationSufficiencyAssessment, + SufficiencyDimension, +) +from thoth.domain.evidence_basis import EvidenceCommitBasis, EvidenceSourceBasis, EvidenceSpanBasis +from thoth.domain.evidence_graph import ( + EvidenceAuditRecord, + EvidenceLinkRecord, + EvidenceSourceRecord, + ObservationRecord, +) +from thoth.domain.governance import ProjectPolicy, SourceBinding +from thoth.domain.ingestion import IngestionResult +from thoth.domain.investigation import InvestigationRecord +from thoth.domain.project import Project, WorkThread +from thoth.ports.acquisition import AcquisitionTraceStorePort, EvidenceUnitOfWorkPort +from thoth.ports.governance import ProjectPolicyReaderPort +from thoth.ports.investigation import InvestigationStorePort + +T0 = datetime(2026, 10, 8, tzinfo=UTC) +HASH = "a" * 64 + + +class Clock: + def now(self) -> datetime: + return T0 + + +class Ids: + def __init__(self) -> None: + self.count = 0 + + def new(self, prefix: str) -> str: + self.count += 1 + return f"{prefix}:{self.count}" + + +def span(identifier: str, text: str) -> EvidenceSpan: + return EvidenceSpan.model_validate( + { + "span_id": identifier, + "project_id": "p", + "artifact_id": "artifact:1", + "source_version_id": "version:1", + "locator": {"page": 1}, + "exact_text": text, + "text_sha256": hashlib.sha256(text.encode()).hexdigest(), + "extraction_method": "fixture", + "support_state": "EXTRACTED", + "authority_state": "UNCLASSIFIED", + "verification_state": "NOT_CHECKED", + "cutoff_state": "ELIGIBLE", + } + ) + + +def route(group: str, *, max_results: int = 1) -> dict[str, object]: + return { + "evidence_group": group, + "match_terms": [group], + "connector_id": "fixture-reader", + "selector": {"relative_path": group + ".md"}, + "query_families": [group], + "max_waves": 1, + "max_results": max_results, + } + + +def policy(routes: list[dict[str, object]]) -> ProjectPolicy: + return ProjectPolicy( + policy_id="policy:p", + project_id="p", + version=1, + policy_digest=HASH, + created_at=T0, + payload={ + "connector_allowlist": ["fixture-reader"], + "connector_allowed_egress_classes": ["NONE"], + "max_source_security_class": "RESTRICTED", + "sandbox_runtime_allowlist": [], + "sandbox_network_policy": "DENY_ALL", + "sandbox_allowed_hosts": [], + "acquisition_routes": routes, + }, + ) + + +def acquisition(spans: tuple[EvidenceSpan, ...]) -> ConnectorAcquisition: + artifact = ArtifactEnvelope( + artifact_id="artifact:1", + project_id="p", + source_uri="fixture://registry", + media_type="text/plain", + byte_sha256=HASH, + authority=AuthorityState.UNCLASSIFIED, + cutoff_state=CutoffState.ELIGIBLE, + security_class=SecurityClass.INTERNAL, + retrieved_at=T0, + parser_name="fixture", + parser_version="1", + ) + return ConnectorAcquisition( + ingestion=IngestionResult( + document=StructuralDocument( + artifact=artifact, nodes=(), extraction_coverage="COMPLETE" + ), + source_version_id="version:1", + evidence_candidates=spans, + object_digest=HASH, + ), + run=ConnectorRunRecord( + connector_run_id="run:1", + project_id="p", + connector_id="fixture-reader", + operation=ConnectorOperation.READ, + state="SUCCEEDED", + requested_scope_digest=HASH, + policy_digest=HASH, + started_at=T0, + completed_at=T0, + ), + receipt=ConnectorReceipt( + connector_run_id="run:1", + project_id="p", + connector_id="fixture-reader", + driver_version="1", + policy_digest=HASH, + source_uri="fixture://registry", + native_version=NativeVersion(kind=NativeVersionKind.CONTENT_HASH, value=HASH), + content_sha256=HASH, + byte_size=sum(len(s.exact_text.encode()) for s in spans), + recorded_at=T0, + receipt_digest=HASH, + ), + binding=SourceBinding( + binding_id="binding:1", + project_id="p", + artifact_id="artifact:1", + created_at=T0, + updated_at=T0, + ), + source=EvidenceSourceRecord( + source_id="source:1", + project_id="p", + artifact_id="artifact:1", + connector_ref="fixture-reader", + uri="fixture://registry", + artifact_type="text/plain", + sha256=HASH, + authority_status=AuthorityState.UNCLASSIFIED, + security_class="INTERNAL", + cutoff_eligibility=CutoffState.ELIGIBLE, + lineage_root_id="source:1", + source_digest=HASH, + created_at=T0, + ), + ) + + +def staged_evidence(lead: AcquisitionLead, selected: EvidenceSpan) -> EvidenceAtomicCommit: + return EvidenceAtomicCommit( + basis=EvidenceCommitBasis( + project_id="p", + sources=( + EvidenceSourceBasis( + project_id="p", + artifact_id="artifact:1", + artifact_digest=HASH, + source_id="source:1", + source_digest=HASH, + source_state_digest=HASH, + ), + ), + spans=( + EvidenceSpanBasis( + project_id="p", + artifact_id="artifact:1", + span_id=selected.span_id, + source_version_id="version:1", + span_digest=HASH, + ), + ), + ), + observation=ObservationRecord( + observation_id="observation:1", + project_id="p", + span_ids=(selected.span_id,), + observed_statement=selected.exact_text, + observer_group="fixture-reader", + independence_basis="fixture", + provenance_class="PROJECT_SOURCE", + observation_digest=HASH, + created_at=T0, + ), + lead=lead, + claim_candidate=EvidenceLinkRecord( + evidence_id="link:1", + project_id="p", + thread_id="thread:1", + target_type="DECISION_OBJECT", + target_id="object:1", + relation="SUPPORTS", + source_ids=("source:1",), + span_ids=(selected.span_id,), + observation_ids=("observation:1",), + independence_group="fixture-reader", + evidence_digest=HASH, + created_at=T0, + ), + audit_records=( + EvidenceAuditRecord( + audit_id="audit:evidence", + project_id="p", + evidence_ref="link:1", + event_type="evidence/updated", + payload={}, + event_digest=HASH, + created_at=T0, + ), + ), + ) + + +class Harness: + def __init__(self, spans: tuple[EvidenceSpan, ...], *, max_results: int = 1) -> None: + self.events: list[str] = [] + self.requests: list[ConnectorAccessRequest] = [] + self.committed: list[EvidenceAtomicCommit] = [] + self.stage_inputs: list[dict[str, object]] = [] + self.acquire_failure: BaseException | None = None + self.stage_failure: Exception | None = None + self.commit_failure: Exception | None = None + self.exhausted_wave = False + self.stored_policy: ProjectPolicy | None = policy( + [route("dataset_version", max_results=max_results)] + ) + self.acquired = acquisition(spans) + self.project = Project( + project_id="p", + name="fixture", + cutoff_at=T0, + overlay="fixture", + policy_binding_ref="policy:p", + ) + self.thread = WorkThread( + thread_id="thread:1", + project_id="p", + cycle_id="cycle:1", + problem="Question", + scope={"workstream": "fixture"}, + working_head_digest=HASH, + ) + dimension = SufficiencyDimension( + status=DimensionStatus.UNKNOWN, evidence_refs=(), reason="fixture" + ) + self.assessment = InformationSufficiencyAssessment( + assessment_id="assessment:1", + assessment_revision_id="revision:1", + project_id="p", + target_object_id="object:1", + cutoff_at=T0, + decision_question="Question", + scope_identity=dimension, + criterion_authority=dimension, + evidence_coverage=dimension, + comparability=dimension, + counterevidence=dimension, + instrumentation=dimension, + expert_semantics=dimension, + derived_status=(SufficiencyStatus.EVIDENCE_ACQUISITION_REQUIRED,), + policy_version="fixture", + input_head_set_digest=HASH, + ) + ids, clock = Ids(), Clock() + store = Mock(spec=InvestigationStorePort) + store.update.return_value = True + actual_investigations = InvestigationService(store=store, clock=clock, ids=ids) + investigations = Mock(spec=InvestigationService) + + def start(**kwargs: object) -> InvestigationRecord: + self.events.append("investigation.start") + return cast(Callable[..., InvestigationRecord], actual_investigations.start)(**kwargs) + + def wave(record: InvestigationRecord, **kwargs: object) -> InvestigationRecord: + self.events.append("investigation.wave") + if self.exhausted_wave: + record = record.model_copy(update={"budget_usage": record.budget}) + return cast(Callable[..., InvestigationRecord], actual_investigations.record_wave)( + record, **kwargs + ) + + def stop(record: InvestigationRecord, *, reason: str) -> InvestigationRecord: + self.events.append("investigation.stop:" + reason) + return actual_investigations.stop(record, reason=reason) + + investigations.start.side_effect = start + investigations.record_wave.side_effect = wave + investigations.stop.side_effect = stop + connectors = Mock(spec=ConnectorService) + + def capabilities() -> tuple[dict[str, object], ...]: + self.events.append("connector.capabilities") + return ({"connector_id": "fixture-reader", "driver_version": "1"},) + + async def acquire(request: ConnectorAccessRequest) -> ConnectorAcquisition: + self.events.append("connector.await.enter") + self.requests.append(request) + await asyncio.sleep(0) + if self.acquire_failure is not None: + self.events.append("connector.await.raise") + raise self.acquire_failure + self.events.append("connector.await.return") + return self.acquired + + connectors.capabilities.side_effect = capabilities + connectors.acquire_one = AsyncMock(side_effect=acquire) + graph = Mock(spec=EvidenceGraphService) + + def stage(**kwargs: object) -> EvidenceAtomicCommit: + self.events.append("evidence.stage") + self.stage_inputs.append(kwargs) + if self.stage_failure is not None: + raise self.stage_failure + selected = next( + s for s in spans if s.span_id == cast(tuple[str, ...], kwargs["span_ids"])[0] + ) + return staged_evidence(cast(AcquisitionLead, kwargs["lead"]), selected) + + graph.stage_link.side_effect = stage + uow = Mock(spec=EvidenceUnitOfWorkPort) + + def commit(value: EvidenceAtomicCommit) -> None: + self.events.append("evidence.commit") + if self.commit_failure is not None: + raise self.commit_failure + self.committed.append(value) + + uow.commit.side_effect = commit + policies = Mock(spec=ProjectPolicyReaderPort) + + def read_policy(project: str) -> ProjectPolicy | None: + assert project == "p" + self.events.append("policy.read") + return self.stored_policy + + policies.read_policy.side_effect = read_policy + traces = Mock(spec=AcquisitionTraceStorePort) + + def intent(value: SearchIntent) -> None: + self.events.append("intent.put") + + traces.put_search_intent.side_effect = intent + self.coordinator = AcquisitionCoordinator( + connectors=cast(ConnectorService, connectors), + evidence_graph=cast(EvidenceGraphService, graph), + evidence_unit_of_work=uow, + investigations=cast(InvestigationService, investigations), + traces=traces, + policies=policies, + clock=clock, + ids=ids, + ) diff --git a/tests/unit/services/counter_wave_support.py b/tests/unit/services/counter_wave_support.py new file mode 100644 index 0000000..4da4068 --- /dev/null +++ b/tests/unit/services/counter_wave_support.py @@ -0,0 +1,327 @@ +"""Real challenger/reviewer records with controlled I/O and finalization.""" + +from __future__ import annotations + +import asyncio +import hashlib +from dataclasses import replace +from datetime import datetime, timedelta +from typing import cast +from unittest.mock import AsyncMock, Mock + +from pydantic import JsonValue +from tests.unit.services.acquisition_coordinator_support import ( + HASH, + T0, + Ids, + acquisition, + span, + staged_evidence, +) + +from thoth.application.services.connector_service import ConnectorAcquisition, ConnectorService +from thoth.application.services.critical_counter_search import CounterSearchBasis +from thoth.application.services.critical_counter_search_coordinator import ( + CriticalCounterSearchCoordinator, + CriticalCounterSearchExecution, +) +from thoth.application.services.critical_counter_search_finalization import ( + CounterSearchFinalizationService, +) +from thoth.application.services.evidence_graph_service import EvidenceGraphService +from thoth.application.services.investigation_service import InvestigationService +from thoth.application.services.research_identity_service import ( + ResearchContext, + ResearchIdentityService, +) +from thoth.domain.acquisition import AcquisitionLead, EvidenceAtomicCommit +from thoth.domain.connectors import ConnectorAccessRequest +from thoth.domain.counterevidence import ( + CounterSearchBudgetState, + CounterWaveRecord, + IndependentGateReview, +) +from thoth.domain.enums import ( + AuthorityState, + CausalDepth, + CausalLocus, + HypothesisStatus, + PortfolioStatus, + SecurityClass, +) +from thoth.domain.evidence import EvidenceSpan +from thoth.domain.hypothesis import Hypothesis, HypothesisPortfolio +from thoth.domain.policy import ( + AuthoritativeExecutionPolicy, + CounterSearchLimits, + CounterSearchRoute, +) +from thoth.domain.project import Project, WorkThread +from thoth.ports.acquisition import AcquisitionTraceStorePort, EvidenceUnitOfWorkPort +from thoth.ports.artifact_ledger import ArtifactLedgerPort +from thoth.ports.evidence_graph import EvidenceGraphStorePort +from thoth.ports.governance import GovernanceStorePort, ProjectPolicyReaderPort +from thoth.ports.investigation import InvestigationStorePort +from thoth.ports.ledger import LedgerPort + + +class Clock: + def __init__(self) -> None: + self.seconds = 0 + + def now(self) -> datetime: + return T0 + timedelta(seconds=self.seconds) + + +class Harness(CriticalCounterSearchCoordinator): + def __init__(self, texts: tuple[str, ...], *, confirmations: int = 2) -> None: + self.events: list[str] = [] + self.requests: list[ConnectorAccessRequest] = [] + self.committed: list[EvidenceAtomicCommit] = [] + self.commit_attempts = 0 + self.fail_commit_at: int | None = None + self.fail_acquire_at: int | None = None + self.failure: BaseException | None = None + self.head_change_after: int | None = None + self.advance_seconds = 0 + self.clock = Clock() + self.heads = {"HYPOTHESIS:portfolio:1": HASH, "DECISION_OBJECT:object:1": HASH} + self.detached: list[str] = [] + self.finalized: dict[str, object] | None = None + self.project = Project( + project_id="p", + name="fixture", + cutoff_at=T0, + overlay="fixture", + policy_binding_ref="policy:p", + ) + self.thread = WorkThread( + thread_id="thread:1", + project_id="p", + cycle_id="cycle:1", + problem="Question", + working_head_digest=HASH, + ) + self.leading = Hypothesis( + hypothesis_id="hypothesis:1", + object_id="object:1", + statement="Candidate", + observed_problem="Mismatch", + primary_locus=CausalLocus.INPUT_MATERIAL_DATA, + causal_depth=CausalDepth.INTERMEDIATE, + scope_conditions={}, + support_evidence_refs=("span:primary",), + counterevidence_refs=(), + missing_evidence=("primary",), + counterevidence_queries=("counter",), + assumptions=(), + uncertainty="unknown", + predicted_observations=(), + discriminating_tests=(), + status=HypothesisStatus.DRAFT, + ) + self.portfolio = HypothesisPortfolio( + portfolio_id="portfolio:1", + object_id="object:1", + hypotheses=(self.leading,), + status=PortfolioStatus.DRAFT, + generated_from_head_set=HASH, + alternatives_considered=("alternative",), + next_checks=("check",), + uncertainty_reserve="unknown", + ) + self.routes = tuple( + CounterSearchRoute( + route_id=f"route:{i}", + target_loci=(CausalLocus.INPUT_MATERIAL_DATA.value,), + connector_id="fixture-reader", + selector={"index": i}, + query_families=("independent", "alternative"), + source_territory=f"territory:{i}", + independence_group=f"group:{i}", + alternative_explanation="other mechanism", + support_match_terms=("supports",), + counter_match_terms=("counter",), + priority=100 - i * 10, + estimated_cost_microunits=1, + checkpoint_after=True, + ) + for i in range(len(texts)) + ) + self.policy = AuthoritativeExecutionPolicy( + policy_id="policy:p", + project_id="p", + policy_revision=1, + policy_digest=HASH, + connector_allowlist=("fixture-reader",), + connector_allowed_egress_classes=("NONE",), + max_source_security_class=SecurityClass.RESTRICTED, + sandbox_runtime_allowlist=(), + sandbox_network_policy="DENY_ALL", + sandbox_allowed_hosts=(), + counter_search_routes=self.routes, + counter_search_limits=CounterSearchLimits( + max_waves=6, + max_results=20, + max_documents=6, + max_bytes=10000, + max_tool_calls=12, + max_cost_microunits=100, + max_time_seconds=30, + min_independent_confirmations=confirmations, + ), + ) + self.basis = CounterSearchBasis( + HASH, + HASH, + ResearchContext( + project_id="p", + object_id="object:1", + heads=dict(self.heads), + portfolio_id="portfolio:1", + ), + ) + self.acquisitions: list[ConnectorAcquisition] = [] + for i, text in enumerate(texts): + base = acquisition((span(f"span:{i}", text),)) + self.acquisitions.append( + replace( + base, + ingestion=base.ingestion.model_copy( + update={"object_digest": hashlib.sha256(text.encode()).hexdigest()} + ), + source=base.source.model_copy( + update={ + "source_id": f"source:{i}", + "lineage_root_id": f"lineage:{i}", + "authority_status": AuthorityState.OFFICIAL, + } + ), + binding=base.binding.model_copy(update={"binding_id": f"binding:{i}"}), + ) + ) + connectors = Mock(spec=ConnectorService) + + async def acquire(request: ConnectorAccessRequest) -> ConnectorAcquisition: + self.events.append("io.enter") + self.requests.append(request) + await asyncio.sleep(0) + if len(self.requests) == self.fail_acquire_at: + assert self.failure is not None + self.events.append("io.raise") + raise self.failure + index = int(str(request.selector["index"])) + result = self.acquisitions[index] + assert result.receipt.byte_size <= request.max_bytes + self.clock.seconds += self.advance_seconds + if len(self.requests) == self.head_change_after: + self.heads["HYPOTHESIS:portfolio:1"] = "b" * 64 + self.events.append("io.return") + return result + + connectors.acquire_one = AsyncMock(side_effect=acquire) + graph = Mock(spec=EvidenceGraphService) + + def stage(**kwargs: object) -> EvidenceAtomicCommit: + self.events.append("stage") + selected = cast(tuple[str, ...], kwargs["span_ids"])[0] + candidate = next( + s + for a in self.acquisitions + for s in a.ingestion.evidence_candidates + if s.span_id == selected + ) + return staged_evidence(cast(AcquisitionLead, kwargs["lead"]), candidate) + + graph.stage_link.side_effect = stage + uow = Mock(spec=EvidenceUnitOfWorkPort) + + def commit(value: EvidenceAtomicCommit) -> None: + self.events.append("commit") + self.commit_attempts += 1 + if self.commit_attempts == self.fail_commit_at: + raise RuntimeError("controlled commit failure") + self.committed.append(value) + + uow.commit.side_effect = commit + governance = Mock(spec=GovernanceStorePort) + + def detach(project: str, binding: str, *, state: str, updated_at: str) -> None: + assert project == "p" and state == "DETACHED" and updated_at + self.events.append("detach:" + binding) + self.detached.append(binding) + + governance.set_source_binding_state.side_effect = detach + ledger = Mock(spec=LedgerPort) + + def read_heads(project: str) -> dict[str, str]: + assert project == "p" + return dict(self.heads) + + ledger.read_heads.side_effect = read_heads + artifacts = Mock(spec=ArtifactLedgerPort) + primary_span = span("span:primary", "primary observation").model_copy( + update={"artifact_id": "artifact:primary"} + ) + primary_source = acquisition((primary_span,)).source.model_copy( + update={ + "artifact_id": "artifact:primary", + "source_id": "source:primary", + "connector_ref": "primary-reader", + "lineage_root_id": "primary-lineage", + "authority_status": AuthorityState.OFFICIAL, + } + ) + + def read_evidence(identifier: str) -> EvidenceSpan | None: + return primary_span if identifier == "span:primary" else None + + artifacts.read_evidence.side_effect = read_evidence + evidence_store = Mock(spec=EvidenceGraphStorePort) + evidence_store.read_source_by_artifact.return_value = primary_source + store = Mock(spec=InvestigationStorePort) + store.update.return_value = True + ids = Ids() + super().__init__( + connectors=cast(ConnectorService, connectors), + evidence_graph=cast(EvidenceGraphService, graph), + evidence_unit_of_work=uow, + evidence_store=evidence_store, + artifacts=artifacts, + investigations=InvestigationService(store=store, clock=self.clock, ids=ids), + traces=Mock(spec=AcquisitionTraceStorePort), + governance=governance, + policies=Mock(spec=ProjectPolicyReaderPort), + ledger=ledger, + research=cast(ResearchIdentityService, Mock(spec=ResearchIdentityService)), + clock=self.clock, + ids=ids, + ) + finalizer = Mock(spec=CounterSearchFinalizationService) + + def finish(**kwargs: object) -> dict[str, JsonValue]: + self.events.append("finalize") + self.finalized = kwargs + return { + "loop_terminal": str(kwargs["loop_terminal"]), + "budget": cast(CounterSearchBudgetState, kwargs["budget"]).model_dump(mode="json"), + "waves": [ + w.model_dump(mode="json") + for w in cast(list[CounterWaveRecord], kwargs["waves"]) + ], + "gate": cast(IndependentGateReview, kwargs["gate"]).model_dump(mode="json"), + } + + finalizer.multi.side_effect = finish + self._finalizer = cast(CounterSearchFinalizationService, finalizer) + + async def run(self) -> CriticalCounterSearchExecution: + return await self._execute_multi_wave( + project=self.project, + thread=self.thread, + portfolio=self.portfolio, + leading=self.leading, + policy=self.policy, + routes=self.routes, + basis=self.basis, + ) diff --git a/tests/unit/services/test_acquisition_coordinator_contract.py b/tests/unit/services/test_acquisition_coordinator_contract.py new file mode 100644 index 0000000..18874ac --- /dev/null +++ b/tests/unit/services/test_acquisition_coordinator_contract.py @@ -0,0 +1,196 @@ +"""Pin acquisition ordering, early exits, partial evidence and collaborator failures.""" + +import asyncio +from typing import cast + +import pytest +from pydantic import JsonValue +from tests.unit.services.acquisition_coordinator_support import ( + HASH, + T0, + Harness, + policy, + route, + span, +) + +from thoth.domain.connectors import ConnectorErrorCode, ConnectorFailure +from thoth.domain.enums import SufficiencyStatus +from thoth.domain.policy import PolicyDenialBasis, PolicyDenialReceipt + + +async def execute(h: Harness, evidence_text: str | None = None): + evidence = () if evidence_text is None else (span("span:existing", evidence_text),) + return await h.coordinator.execute_if_required( + project=h.project, thread=h.thread, assessment=h.assessment, evidence=evidence + ) + + +@pytest.mark.parametrize( + "case", ["not_requested", "missing_policy", "no_routes", "already_covered"] +) +async def test_no_acquisition_means_no_intent_or_connector_io(case: str) -> None: + h = Harness((span("span:first", "dataset_version"),)) + if case == "not_requested": + h.assessment = h.assessment.model_copy( + update={"derived_status": (SufficiencyStatus.ABSTAIN,)} + ) + elif case == "missing_policy": + h.stored_policy = None + elif case == "no_routes": + h.stored_policy = policy([]) + assert await execute(h, "DATASET_VERSION" if case == "already_covered" else None) is None + assert h.events == ([] if case == "not_requested" else ["policy.read"]) + assert h.requests == [] and h.committed == [] + + +async def test_first_missing_route_is_selected_in_policy_order() -> None: + h = Harness((span("span:first", "other_group"),)) + h.stored_policy = policy([route("dataset_version"), route("other_group"), route("last_group")]) + result = await execute(h, "DATASET_VERSION") + assert result is not None + assert h.requests[0].selector == {"relative_path": "other_group.md"} + assert result.projection["remaining_target_gaps"] == ["last_group"] + assert result.projection["terminal_state"] == "SUFFICIENT" + assert len(h.requests) == 1 + + +@pytest.mark.parametrize("first_matches", [True, False]) +async def test_first_span_limit_all_acquired_evidence_and_await_commit_order( + first_matches: bool, +) -> None: + h = Harness( + ( + span("span:first", "dataset_version" if first_matches else "unrelated"), + span("span:later", "dataset_version"), + ) + ) + result = await execute(h) + assert result is not None + assert result.acquired_evidence is h.acquired.ingestion.evidence_candidates + assert result.projection["acquired_evidence_refs"] == ["span:first"] + assert h.stage_inputs[0]["span_ids"] == ("span:first",) + assert ( + h.stage_inputs[0]["observed_statement"] + == h.acquired.ingestion.evidence_candidates[0].exact_text + ) + assert len(h.committed) == 1 and h.committed[0].lead is not None + assert h.committed[0].lead.span_id == "span:first" + # The original gap check uses all acquired spans, including ones outside the link result limit. + assert result.projection["remaining_target_gaps"] == [] + assert result.projection["reanalysis_performed"] is True + usage = cast(dict[str, JsonValue], result.projection["route_usage"]) + assert usage["results_used"] == usage["max_results"] == usage["max_waves"] == 1 + investigation = cast(dict[str, JsonValue], result.projection["investigation"]) + assert investigation["budget_usage"] == investigation["current_wave"] == 1 + assert h.events == [ + "policy.read", + "investigation.start", + "intent.put", + "connector.capabilities", + "connector.await.enter", + "connector.await.return", + "evidence.stage", + "evidence.commit", + "investigation.wave", + "investigation.stop:SUFFICIENT", + ] + request = h.requests[0] + assert request.actor_id == "agent:authorized-evidence-acquisition" + assert request.max_bytes == 64 * 1024 * 1024 and request.policy_digest == HASH + assert request.policy_id == "policy:p" and request.policy_revision == 1 + + +async def test_empty_results_saturate_without_staging_or_commit() -> None: + h = Harness(()) + result = await execute(h) + assert result is not None and result.acquired_evidence == () + assert result.projection["terminal_state"] == "SEARCH_SATURATED" + assert result.projection["reanalysis_performed"] is False + assert result.projection["minimum_question"] == "Provide dataset_version evidence." + assert h.stage_inputs == [] and h.committed == [] + assert h.events[-1] == "investigation.stop:SEARCH_SATURATED" + + +async def test_partial_evidence_is_committed_and_returned_with_remaining_gap() -> None: + h = Harness((span("span:first", "unrelated finding"),)) + result = await execute(h) + assert result is not None + assert result.projection["terminal_state"] == "SEARCH_SATURATED" + assert result.projection["remaining_target_gaps"] == ["dataset_version"] + assert result.projection["reanalysis_performed"] is False + assert result.acquired_evidence == h.acquired.ingestion.evidence_candidates + assert len(h.committed) == 1 and h.events[-1] == "investigation.stop:SEARCH_SATURATED" + + +@pytest.mark.parametrize("kind", ["policy", "driver", "cancelled_receipt"]) +async def test_typed_connector_failures_preserve_hold_and_failure_projection(kind: str) -> None: + h = Harness(()) + denial = ( + PolicyDenialReceipt( + project_id="p", + action_kind="CONNECTOR", + denial_basis=PolicyDenialBasis.SCOPE_DENIED, + denied_at=T0, + receipt_digest=HASH, + ) + if kind == "policy" + else None + ) + code = ( + ConnectorErrorCode.CANCELLED + if kind == "cancelled_receipt" + else ConnectorErrorCode.DRIVER_ERROR + ) + h.acquire_failure = ConnectorFailure(code, "controlled failure", policy_denial=denial) + result = await execute(h) + assert result is not None and result.acquired_evidence == () + assert result.projection["terminal_state"] == ("POLICY_BLOCKED" if denial else "FAILED") + assert result.projection["reanalysis_performed"] is False + assert result.projection["policy_denial"] == ( + None if denial is None else denial.model_dump(mode="json") + ) + assert h.events[-1] == "investigation.stop:" + ( + "PERMISSION_BLOCKED" if denial else "CONNECTOR_FAILED" + ) + assert h.committed == [] and h.stage_inputs == [] + + +async def test_task_cancellation_propagates_without_fake_terminal_or_commit() -> None: + h = Harness(()) + h.acquire_failure = asyncio.CancelledError() + with pytest.raises(asyncio.CancelledError): + await execute(h) + assert h.events[-1] == "connector.await.raise" + assert h.committed == [] and h.stage_inputs == [] + + +async def test_staging_failure_propagates_without_commit() -> None: + h = Harness((span("span:first", "dataset_version"),)) + h.stage_failure = ValueError("staging failed") + with pytest.raises(ValueError, match="staging failed"): + await execute(h) + assert h.events[-1] == "evidence.stage" and h.committed == [] + + +async def test_commit_failure_returns_all_acquired_spans_and_original_target_gaps() -> None: + h = Harness((span("span:first", "dataset_version"), span("span:later", "extra"))) + h.commit_failure = RuntimeError("commit failed") + result = await execute(h) + assert result is not None + assert result.projection["terminal_state"] == "FAILED" + assert result.projection["remaining_target_gaps"] == ["dataset_version"] + assert result.projection["reanalysis_performed"] is False + assert result.acquired_evidence is h.acquired.ingestion.evidence_candidates + assert "observation" not in result.projection and "connector_receipt" not in result.projection + assert h.events[-2:] == ["evidence.commit", "investigation.stop:EVIDENCE_COMMIT_FAILED"] + assert h.committed == [] + + +async def test_exhausted_wave_error_after_commit_is_not_retried_or_reclassified() -> None: + h = Harness((span("span:first", "dataset_version"),)) + h.exhausted_wave = True + with pytest.raises(ValueError, match="investigation budget is exhausted"): + await execute(h) + assert h.events[-2:] == ["evidence.commit", "investigation.wave"] + assert len(h.committed) == len(h.requests) == 1 diff --git a/tests/unit/services/test_counter_wave_contract.py b/tests/unit/services/test_counter_wave_contract.py new file mode 100644 index 0000000..8d5fc58 --- /dev/null +++ b/tests/unit/services/test_counter_wave_contract.py @@ -0,0 +1,197 @@ +"""Loop budgets and partial evidence are characterized without real clocks or network I/O.""" + +import asyncio +from dataclasses import replace +from typing import cast + +import pytest +from pydantic import JsonValue +from tests.unit.services.counter_wave_support import Harness + +from thoth.domain.connectors import ConnectorErrorCode, ConnectorFailure +from thoth.domain.enums import AuthorityState + + +@pytest.mark.parametrize( + "limit", ["waves", "results", "documents", "bytes", "tools", "cost", "time"] +) +async def test_each_cumulative_budget_stops_before_another_io_and_keeps_partial_evidence( + limit: str, +) -> None: + h = Harness(("counter first", "counter second", "counter third"), confirmations=10) + field, cap = { + "waves": ("max_waves", 1), + "results": ("max_results", 1), + "documents": ("max_documents", 1), + "bytes": ("max_bytes", len("counter first")), + "tools": ("max_tool_calls", 2), + "cost": ("max_cost_microunits", 1), + "time": ("max_time_seconds", 1), + }[limit] + h.policy = h.policy.model_copy( + update={ + "counter_search_limits": h.policy.counter_search_limits.model_copy(update={field: cap}) + } + ) + h.advance_seconds = 2 if limit == "time" else 0 + result = await h.run() + assert result.projection["loop_terminal"] == "BUDGET_EXHAUSTED" + assert len(h.requests) == len(h.committed) == 1 + budget = cast(dict[str, JsonValue], result.projection["budget"]) + assert ( + budget["used_waves"], + budget["used_results"], + budget["used_documents"], + budget["used_bytes"], + budget["used_tool_calls"], + budget["used_cost_microunits"], + budget["used_model_calls"], + ) == ( + 1, + 1, + 1, + len("counter first"), + 2, + 1, + 0, + ) + assert budget["used_time_seconds"] == (1 if limit == "time" else 0) + gate = cast(dict[str, JsonValue], result.projection["gate"]) + assert gate["terminal"] == "UNRESOLVED_FAILED" and gate["accepted_evidence_refs"] == [] + + +async def test_duplicate_content_is_charged_and_detached_but_not_independently_confirmed() -> None: + h = Harness(("counter duplicate", "counter duplicate", "counter distinct")) + result = await h.run() + assert result.projection["loop_terminal"] == "ELIMINATED_WITHIN_SCOPE" + waves = cast(list[dict[str, JsonValue]], result.projection["waves"]) + assert [w["route_id"] for w in waves] == ["route:0", "route:1", "route:2"] + assert [w["deduplicated"] for w in waves] == [False, True, False] + assert [w["decision_rank_after"] for w in waves] == [-1, -1, -2] + assert waves[1]["accepted_evidence_refs"] == [] + assert all(w["checkpoint_digest"] for w in waves) + assert h.detached == ["binding:1"] and len(h.committed) == 2 + budget = cast(dict[str, JsonValue], result.projection["budget"]) + assert budget["used_waves"] == budget["used_documents"] == 3 + + +async def test_shared_lineage_is_not_an_independent_confirmation_even_with_new_content() -> None: + h = Harness(("counter first", "counter second", "counter third")) + a = h.acquisitions[1] + h.acquisitions[1] = replace( + a, source=a.source.model_copy(update={"lineage_root_id": "lineage:0"}) + ) + result = await h.run() + assert result.projection["loop_terminal"] == "ELIMINATED_WITHIN_SCOPE" + waves = cast(list[dict[str, JsonValue]], result.projection["waves"]) + assert [w["deduplicated"] for w in waves] == [False, False, False] + assert [w["sufficiency_after"] for w in waves] == [ + "confirmations:1", + "confirmations:1", + "confirmations:2", + ] + assert len(h.committed) == 3 + + +async def test_two_low_voi_waves_stop_before_third_route() -> None: + h = Harness(("unrelated first", "unrelated second", "counter third")) + result = await h.run() + assert result.projection["loop_terminal"] == "SEARCH_SATURATED" + assert len(h.requests) == 2 and h.committed == [] + assert h.detached == ["binding:0", "binding:1"] + + +@pytest.mark.parametrize( + ("case", "expected"), + [ + ("policy", "POLICY_BLOCKED"), + ("authority", "AUTHORITY_REQUIRED"), + ("prohibited", "ABSTAINED"), + ("depth", "ABSTAINED"), + ], +) +async def test_preflight_blocks_do_not_spend_io_budget(case: str, expected: str) -> None: + h = Harness(("counter first", "counter second")) + if case == "policy": + h.policy = h.policy.model_copy(update={"connector_allowlist": ()}) + else: + changes: dict[str, dict[str, object]] = { + "authority": {"source_authority": AuthorityState.UNCLASSIFIED}, + "prohibited": {"context_tags": ("HIDDEN_HOLDOUT",)}, + "depth": {"depth": h.policy.counter_search_limits.max_depth + 1}, + } + h.routes = tuple(r.model_copy(update=changes[case]) for r in h.routes) + result = await h.run() + assert result.projection["loop_terminal"] == expected + assert h.requests == [] and h.committed == [] and result.projection["waves"] == [] + assert cast(dict[str, JsonValue], result.projection["budget"])["used_tool_calls"] == 0 + + +async def test_conflicting_partial_evidence_overrides_budget_terminal() -> None: + h = Harness(("counter first", "supports second", "counter third")) + h.policy = h.policy.model_copy( + update={ + "counter_search_limits": h.policy.counter_search_limits.model_copy( + update={"max_cost_microunits": 2} + ) + } + ) + result = await h.run() + assert result.projection["loop_terminal"] == "ABSTAINED" + assert len(h.requests) == len(h.committed) == 2 + gate = cast(dict[str, JsonValue], result.projection["gate"]) + assert gate["terminal"] == "UNRESOLVED_CONFLICT" and gate["accepted_evidence_refs"] == [] + + +async def test_head_change_keeps_spent_budget_and_prior_evidence() -> None: + h = Harness(("counter first", "counter second", "counter third")) + h.head_change_after = 2 + result = await h.run() + assert result.projection["loop_terminal"] == "ABSTAINED" + assert len(h.requests) == 2 and len(h.committed) == 1 + assert h.detached == ["binding:1"] and len(cast(list[object], result.projection["waves"])) == 1 + assert cast(dict[str, JsonValue], result.projection["budget"])["used_waves"] == 2 + + +async def test_commit_failure_keeps_prior_evidence_and_spent_budget() -> None: + h = Harness(("counter first", "counter second")) + h.fail_commit_at = 2 + result = await h.run() + assert result.projection["loop_terminal"] == "SEARCH_SATURATED" + assert h.commit_attempts == 2 and len(h.committed) == 1 and h.detached == ["binding:1"] + waves = cast(list[dict[str, JsonValue]], result.projection["waves"]) + assert waves[1]["computed_voi"] == "0" and waves[1]["sufficiency_after"] == "confirmations:1" + assert cast(dict[str, JsonValue], result.projection["budget"])["used_tool_calls"] == 4 + + +async def test_typed_connector_failure_is_not_counted_as_a_successful_wave() -> None: + h = Harness(("counter first", "counter second")) + h.fail_acquire_at = 2 + h.failure = ConnectorFailure(ConnectorErrorCode.DRIVER_ERROR, "controlled failure") + result = await h.run() + assert result.projection["loop_terminal"] == "ABSTAINED" + assert len(h.requests) == 2 and len(h.committed) == 1 + assert cast(dict[str, JsonValue], result.projection["budget"])["used_waves"] == 1 + + +async def test_cancel_propagates_with_prior_evidence_and_without_fake_finalization() -> None: + h = Harness(("counter first", "counter second")) + h.fail_acquire_at = 2 + h.failure = asyncio.CancelledError() + with pytest.raises(asyncio.CancelledError): + await h.run() + assert len(h.committed) == 1 and h.finalized is None and h.events[-1] == "io.raise" + + +async def test_no_challenger_plan_preserves_the_original_error() -> None: + h = Harness(("counter first",)) + h.policy = h.policy.model_copy( + update={ + "counter_search_limits": h.policy.counter_search_limits.model_copy( + update={"max_tool_calls": 0} + ) + } + ) + with pytest.raises(ValueError, match="multi-wave counter-search produced no challenger plan"): + await h.run() + assert h.requests == [] and h.committed == [] and h.finalized is None diff --git a/tests/unit/test_csv_parser_rows.py b/tests/unit/test_csv_parser_rows.py new file mode 100644 index 0000000..a407c1b --- /dev/null +++ b/tests/unit/test_csv_parser_rows.py @@ -0,0 +1,113 @@ +"""A CSV file is read by row: one header node and one node per data row, as "column=value" pairs.""" + +from __future__ import annotations + +from tests.unit.test_research_structured_block_context import _artifact, _spans + +from thoth.adapters.parsers.csv_parser import CsvParser +from thoth.application.services.research_retrieval import lexical_candidates, text_neighbors +from thoth.domain.artifact import StructuralDocument +from thoth.domain.enums import StructuralNodeKind as Kind + + +def parse(raw: bytes) -> StructuralDocument: + return CsvParser().parse(_artifact(raw, "trials.csv", "artifact:csv"), raw) + + +def rows(document: StructuralDocument) -> list[tuple[str, str, str | None, str | None, int | None]]: + return [ + ( + node.kind.value, + node.text or "", + node.locator.cell_range, + node.locator.json_pointer, + node.locator.line, + ) + for node in document.nodes + ] + + +def test_the_header_is_one_node_and_each_data_row_is_one_node_with_its_column_names() -> None: + document = parse( + b"row_kind,row_id,detected\nTARGET_TRIAL,SYN-T-RAIN-03,0\nTARGET_TRIAL,SYN-T-RAIN-04,1\n" + ) + assert document.artifact.parser_version == "1.1.0" + assert rows(document) == [ + ("HEADER", "row_kind, row_id, detected", "R1C1:R1C3", "/header", 1), + ( + "ROW", + "row_kind=TARGET_TRIAL, row_id=SYN-T-RAIN-03, detected=0", + "R2C1:R2C3", + "/rows/2", + 2, + ), + ( + "ROW", + "row_kind=TARGET_TRIAL, row_id=SYN-T-RAIN-04, detected=1", + "R3C1:R3C3", + "/rows/3", + 3, + ), + ] + + +def test_empty_values_are_left_out_and_a_blank_row_keeps_the_numbering_of_the_file() -> None: + document = parse(b"id,window,detected\nA-1,,1\n\n,W-2,\n,,\n") + assert [(k, t, p) for k, t, _, p, _ in rows(document)] == [ + ("HEADER", "id, window, detected", "/header"), + ("ROW", "id=A-1, detected=1", "/rows/2"), + ("ROW", "window=W-2", "/rows/4"), + ] + + +def test_quoted_values_with_commas_quotes_and_line_breaks_stay_in_their_row() -> None: + raw = b'id,note,n\nA-1,"a, b ""c""",1\nA-2,"two\nlines",2\nA-3,x,3\n' + found = rows(parse(raw)) + assert found[1][1] == 'id=A-1, note=a, b "c", n=1' + assert found[2][1] == "id=A-2, note=two\nlines, n=2" + # the line is where the row starts in the file, so a multi-line value does not shift the next + assert [item[4] for item in found] == [1, 2, 3, 5] + assert [item[3] for item in found] == ["/header", "/rows/2", "/rows/3", "/rows/4"] + + +def test_a_byte_order_mark_is_not_part_of_the_first_column_name() -> None: + document = parse("\ufeffid,value\nA-1,5\n".encode()) + assert [node.text for node in document.nodes] == ["id, value", "id=A-1, value=5"] + + +def test_a_file_without_a_header_row_is_read_with_column_numbers() -> None: + document = parse(b"A-1,5,rain\nA-2,7,dry\n") + assert [node.kind for node in document.nodes] == [Kind.ROW, Kind.ROW] + assert [node.text for node in document.nodes] == [ + "col1=A-1, col2=5, col3=rain", + "col1=A-2, col2=7, col3=dry", + ] + assert [node.locator.json_pointer for node in document.nodes] == ["/rows/1", "/rows/2"] + + +def test_a_missing_column_name_falls_back_to_its_column_number() -> None: + document = parse(b"id,,unit\nA-1,5,ms\n") + assert document.nodes[1].text == "id=A-1, col2=5, unit=ms" + + +def test_a_short_row_is_a_candidate_and_is_read_with_its_neighbors() -> None: + document = parse(b"unit,count\nms,12\nms,13\nms,14\n") + spans = _spans(document, "csv") + assert min(len(span.exact_text) for span in spans) < 24 + found = lexical_candidates("What is the count in ms?", (), spans) + assert {span.exact_text for span in found} >= {"unit=ms, count=12", "unit=ms, count=13"} + anchor = next(span for span in spans if span.exact_text == "unit=ms, count=13") + near = [span.exact_text for span in text_neighbors(anchor, spans)] + assert "unit, count" in near # the header comes with the row + + +def test_a_large_file_reads_a_row_with_the_row_before_and_after_and_the_header() -> None: + body = "".join(f"T-{n:02d},{n % 2}\n" for n in range(40)) + spans = _spans(parse(("id,detected\n" + body).encode()), "big") + anchor = next(span for span in spans if span.exact_text == "id=T-20, detected=0") + assert {span.exact_text for span in text_neighbors(anchor, spans)} == { + "id, detected", + "id=T-19, detected=1", + "id=T-20, detected=0", + "id=T-21, detected=1", + } diff --git a/tests/unit/test_hypothesis_v3_metrics.py b/tests/unit/test_hypothesis_v3_metrics.py index da581ac..40f0a27 100644 --- a/tests/unit/test_hypothesis_v3_metrics.py +++ b/tests/unit/test_hypothesis_v3_metrics.py @@ -5,7 +5,15 @@ import json from pathlib import Path -from scripts.hypothesis_v3_metrics import find_portfolio, main, measure +import pytest +from scripts.hypothesis_v3_metrics import ( + fill_report, + find_portfolio, + find_table_fill, + main, + measure, + worst_for_item, +) def _row(hypothesis_id: str, expected: str, basis: list[str] | None = None) -> dict[str, object]: @@ -48,3 +56,197 @@ def test_the_portfolio_is_found_in_a_thread_read_answer(tmp_path: Path) -> None: path.write_text(json.dumps(wrapped), encoding="utf-8") assert main([str(path)]) == 0 assert main([str(tmp_path / "missing.json")]) == 1 + + +def test_a_prediction_row_without_basis_is_counted() -> None: + # In _portfolio() the rows either have a basis or are 모름, so none counts. + assert measure(_portfolio())["unbased_prediction_rows"] == 0 + test = { + "test_id": "t4", + "expected_by_hypothesis": [ + _row("h1", "비 옴"), + _row("h2", "안 옴", ["s9"]), + _row("h3", "모름"), + _row("h4", " 모름 "), + ], + } + got = measure({"hypotheses": [{"hypothesis_id": "h1", "discriminating_tests": [test]}]}) + assert got["table_rows"] == 4 and got["unbased_prediction_rows"] == 1 + assert measure({"hypotheses": []})["unbased_prediction_rows"] == 0 + + +RANKING_CASES = Path(__file__).parents[1] / "fixtures" / "hypothesis_eval" / "ranking_cases.json" + + +def test_worst_matches_the_screen_rule_on_every_shared_ranking_case() -> None: + # The same cases testRanking.cases.test.ts reads: one rule, two languages, the same numbers. + cases = json.loads(RANKING_CASES.read_text(encoding="utf-8"))["cases"] + assert len(cases) >= 25 + for case in cases: + candidates = [h["id"] for h in case["hypotheses"] if h.get("elimination") != "REPEATED"] + for item in case["items"]: + if item["id"] in case["expected"]["worst"]: + got, _ = worst_for_item(item, candidates) + assert got == case["expected"]["worst"][item["id"]], (case["id"], item["id"]) + + +def _table(hypothesis_id: str, labels: dict[str, str]) -> dict[str, object]: + return { + "hypothesis_id": hypothesis_id, + "discriminating_tests": [ + { + "test_id": f"test:{hypothesis_id}", + "expected_by_hypothesis": [_row(k, v) for k, v in labels.items()], + } + ], + } + + +def test_a_split_two_and_two_beats_one_against_the_rest() -> None: + same = "그대로" + one_vs_rest = {"h1": "빨라짐", "h2": same, "h3": same, "h4": same} + split = {"h1": same, "h2": same, "h3": "빨라짐", "h4": "빨라짐"} + got = measure( + { + "hypotheses": [ + _table("h1", one_vs_rest), + _table("h2", {"h1": same, "h2": "빨라짐", "h3": same, "h4": same}), + _table("h3", split), + _table("h4", {"h1": same, "h2": same, "h3": same, "h4": "빨라짐"}), + ] + } + ) + assert got["candidates"] == 4 and got["best_worst"] == 2 + assert got["tests_better_than_one_vs_rest"] == 1 + assert [t["worst"] for t in got["worst_by_test"]] == [3, 3, 2, 3] + + +def test_every_test_one_against_the_rest_ties_and_none_is_better() -> None: + # The shape of the 6th live run: five hypotheses, each test one against four. + ids = ["h1", "h2", "h3", "h4", "h5"] + hypotheses = [_table(i, {j: ("빨라짐" if j == i else "그대로") for j in ids}) for i in ids] + got = measure({"hypotheses": hypotheses}) + assert got["candidates"] == 5 and got["best_worst"] == 4 + assert got["tests_better_than_one_vs_rest"] == 0 + assert {t["worst"] for t in got["worst_by_test"]} == {4} + + +def test_a_test_without_a_full_table_cannot_be_counted_and_an_empty_result_has_no_worst() -> None: + partial = _table("h1", {"h1": "a", "h2": "b"}) # h3 is missing + only = {"hypothesis_id": "h2", "discriminating_tests": [{"test_id": "t"}]} + third = {"hypothesis_id": "h3", "discriminating_tests": []} + got = measure({"hypotheses": [partial, only, third]}) + assert [(t["worst"], t["counted"]) for t in got["worst_by_test"]] == [(2, False), (2, False)] + assert got["tests_better_than_one_vs_rest"] == 0 + empty = measure({"hypotheses": []}) + assert empty["candidates"] == 0 and empty["best_worst"] is None + assert empty["tests_better_than_one_vs_rest"] == 0 and empty["worst_by_test"] == [] + + +def test_labels_with_chinese_characters_are_counted_and_nothing_else_changes() -> None: + clean = _table("h1", {"h1": "빨라짐", "h2": "그대로"}) + mixed = _table("h2", {"h1": "그대로", "h2": "確認 후 빨라짐"}) + got = measure({"hypotheses": [clean, mixed]}) + assert got["labels_with_hanja"] == 1 + assert measure({"hypotheses": [clean]})["labels_with_hanja"] == 0 + assert measure({"hypotheses": []})["labels_with_hanja"] == 0 + + +def test_labels_with_kana_are_counted_apart_from_chinese_characters() -> None: + clean = _table("h1", {"h1": "빨라짐", "h2": "그대로"}) + kana = _table("h2", {"h1": "그대로", "h2": "정답 대응また는 과소 집계"}) + han = _table("h3", {"h1": "그대로", "h3": "確認 후 빨라짐"}) + got = measure({"hypotheses": [clean, kana, han]}) + assert got["labels_with_kana"] == 1 and got["labels_with_hanja"] == 1 + assert measure({"hypotheses": [clean]})["labels_with_kana"] == 0 + assert measure({"hypotheses": []})["labels_with_kana"] == 0 + + +def test_foreign_script_fields_count_every_readable_text_with_chinese_characters_or_kana() -> None: + clean = { + "hypothesis_id": "h1", + "statement": "처리 설정이 원인일 수 있다", + "uncertainty": "아직 확인되지 않았다", + "missing_evidence": ["시행별 정답"], + "assumptions": [], + "refutation_conditions": ["반복해도 같으면 제외한다"], + "discriminating_tests": [ + {"test_id": "t1", "procedure_candidate": "재처리한다", "expected_by_hypothesis": []} + ], + } + assert measure({"hypotheses": [clean]})["foreign_script_fields"] == 0 + dirty = { + "hypothesis_id": "h2", + "statement": "確認된 설정", # 1 + "uncertainty": "아직 확인되지 않았다", + "missing_evidence": ["독립した 반복", "시행별 정답", "原本 대조"], # 2 + "counterevidence_queries": ["まだ 찾는다"], # 1 + "predicted_observations": ["누락이 반복된다"], + "assumptions": ["정답이 맞다 的"], # 1 + "refutation_conditions": ["事前에 정한 반복에서 같으면 제외한다"], # 1 + "discriminating_tests": [ + { + "test_id": "t2", + "procedure_candidate": "独立 재집계", # 1 + "expected_if_true": "누락", + "expected_if_alternative": "유지", + "expected_by_hypothesis": [_row("h2", "体계적 증가")], # 1 + } + ], + } + got = measure({"hypotheses": [clean, dirty]}) + assert got["foreign_script_fields"] == 8 + assert got["labels_with_hanja"] == 1 and got["labels_with_kana"] == 0 + + +RECORD = { + "state": "CALLED", + "reason_code": None, + "cells": 8, + "open_before": 5, + "open_after": 2, + "changed_cells": 3, +} + + +def test_the_filling_record_is_found_and_reported_as_shares_of_the_cells() -> None: + wrapped = { + "current_result": {"result": {"portfolio": _portfolio(), "hypothesis_table_fill": RECORD}} + } + assert find_table_fill(wrapped) == RECORD + assert find_table_fill({"portfolio": _portfolio()}) is None + report = fill_report(RECORD) + assert (report["state"], report["changed_cells"]) == ("CALLED", 3) + assert (report["open_ratio_before"], report["open_ratio_after"]) == (0.625, 0.25) + failed = fill_report({"state": "FAILED", "reason_code": "X", "cells": 0}) + assert failed["open_ratio_before"] is None and failed["reason_code"] == "X" + + +def test_main_prints_the_filling_record_only_when_the_result_has_one( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with_fill = tmp_path / "a.json" + plain = tmp_path / "b.json" + result = {"portfolio": _portfolio()} + with_fill.write_text(json.dumps({"result": {**result, "hypothesis_table_fill": RECORD}})) + plain.write_text(json.dumps({"result": result})) + assert main([str(with_fill), str(plain)]) == 0 + first, second = (json.loads(line) for line in capsys.readouterr().out.splitlines()) + assert first["table_fill"]["state"] == "CALLED" and "table_fill" not in second + + +def test_the_katakana_middle_dot_is_not_counted_but_a_kana_letter_is() -> None: + dot = "\u30fb" + item = { + "hypothesis_id": "h1", + "statement": f"조건{dot}측정계", + "missing_evidence": [f"조건{dot}측정계", "독립した 반복"], + "discriminating_tests": [ + { + "test_id": "t", + "expected_by_hypothesis": [_row("h1", f"가{dot}나"), _row("h2", "また")], + } + ], + } + got = measure({"hypotheses": [item]}) + assert got["foreign_script_fields"] == 2 and got["labels_with_kana"] == 1 diff --git a/tests/unit/test_prepare_demo_manifest_spec.py b/tests/unit/test_prepare_demo_manifest_spec.py new file mode 100644 index 0000000..6452aef --- /dev/null +++ b/tests/unit/test_prepare_demo_manifest_spec.py @@ -0,0 +1,132 @@ +"""The demo preparation script reads its file names and result parts from the demo manifest. + +A demo folder without those manifest entries is the radar demo as it always was: the CSV the script +writes for it must stay byte for byte the same. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import sys +from pathlib import Path +from typing import Any + +REPO = Path(__file__).parents[2] +RADAR = REPO / "examples" / "synthetic-radar-demo-v1" + + +def _load() -> Any: + spec = importlib.util.spec_from_file_location( + "prepare_demo_spec", REPO / "scripts" / "prepare_synthetic_radar_demo.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +prepare = _load() + +SPANS = { + "30_RESULT_DRY_SYNTHETIC.yaml": {"detection": "span:d1", "false_track": "span:f1"}, + "31_RESULT_RAIN_SYNTHETIC.yaml": {"detection": "span:d2", "false_track": "span:f2"}, +} + + +def _digest(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def test_the_radar_demo_csv_files_are_byte_for_byte_what_they_were() -> None: + first = prepare.phase1_csv(RADAR, {k: v for k, v in SPANS.items() if "DRY" in k}) + second = prepare.phase2_csv(RADAR, SPANS, "a" * 64) + # taken from the script before it read anything from the manifest + assert (len(first), _digest(first)) == ( + 9083, + "3de8cd82e43a206848d412293f5c14a57bc8189b4bbc2900e4fff1d93c987538", + ) + assert (len(second), _digest(second)) == ( + 1970, + "05a5a983c7bcf7193a2abb6de5846515f14337fb7f78188b8dfe7971d19a98fa", + ) + + +def _demo(root: Path) -> Path: + """A small demo in another field whose manifest names its own files and result parts.""" + (root / "00_MANIFEST_SYNTHETIC.yaml").write_text( + "result_parts: [capacity, resistance]\n" + "requirement_file: 10_REQ.yaml\n" + "test_plan_file: 20_PLAN.yaml\n" + "version_label: synthetic-other-demo-v1\n", + encoding="utf-8", + ) + (root / "10_REQ.yaml").write_text( + "requirement:\n" + " id: SYN-REQ-X-001\n" + " title: Example requirement\n" + " criteria:\n" + " - {id: SYN-C-CAP-A, title: Capacity, measure: capacity, comparator: '>='," + " threshold: '0.80', unit: ratio, condition: temp=a, required: true}\n" + " - {id: SYN-C-RES-A, title: Resistance, measure: resistance, comparator: '<='," + " threshold: '0.30', unit: ratio, condition: temp=a, required: true}\n", + encoding="utf-8", + ) + (root / "20_PLAN.yaml").write_text( + "test_cases:\n" + " - {id: SYN-TC-A, title: Run A, condition: temp=a, covers: [SYN-C-CAP-A, SYN-C-RES-A]}\n", + encoding="utf-8", + ) + (root / "30_RESULT_A.yaml").write_text( + "available_from_phase: 1\n" + "condition: temp=a\n" + "test_case: SYN-TC-A\n" + "observed_at: '2026-01-01T00:00:00Z'\n" + "capacity:\n" + " criterion: SYN-C-CAP-A\n" + " unit: ratio\n" + " value: '0.95'\n" + "resistance:\n" + " criterion: SYN-C-RES-A\n" + " numerator: 3\n" + " denominator: 10\n" + " unit: ratio\n" + " value: '0.30'\n", + encoding="utf-8", + ) + return root + + +def test_a_demo_manifest_names_the_files_and_the_result_parts(tmp_path: Path) -> None: + demo = _demo(tmp_path) + spec = prepare.demo_spec(demo) + assert spec.result_parts == ("capacity", "resistance") + assert (spec.requirement_file, spec.test_plan_file) == ("10_REQ.yaml", "20_PLAN.yaml") + assert spec.version_label == "synthetic-other-demo-v1" + spans = {"30_RESULT_A.yaml": {"capacity": "span:c", "resistance": "span:r"}} + trace = prepare.build_trace_set(demo, 1, spans) + by_criterion = {result.criterion_id: result for result in trace.results} + assert set(by_criterion) == {"SYN-C-CAP-A", "SYN-C-RES-A"} + capacity, resistance = by_criterion["SYN-C-CAP-A"], by_criterion["SYN-C-RES-A"] + assert (capacity.numerator, capacity.denominator) == (None, None) # a mean has no counts + assert (resistance.numerator, resistance.denominator) == (3, 10) + assert capacity.source_span_refs == ("span:c",) and resistance.source_span_refs == ("span:r",) + + +def test_the_value_lines_follow_the_result_parts() -> None: + text = ( + "capacity:\n criterion: X\n value: '1'\nresistance:\n value: '2'\n" + "detection:\n value: '3'\n" + ) + both = prepare.value_lines(text, ("capacity", "resistance")) + assert both == {"capacity": 3, "resistance": 5} + assert prepare.value_lines(text) == {"detection": 7} # the radar parts when none are given + + +def test_a_folder_without_manifest_entries_is_read_as_the_radar_demo() -> None: + spec = prepare.demo_spec(RADAR) + assert spec.result_parts == ("detection", "false_track") + assert spec.requirement_file == "10_REQUIREMENTS_SYNTHETIC.yaml" + assert spec.test_plan_file == "20_TEST_PLAN_SYNTHETIC.yaml" + assert spec.version_label == "synthetic-radar-demo-v1" diff --git a/tests/unit/test_research_structured_block_context.py b/tests/unit/test_research_structured_block_context.py new file mode 100644 index 0000000..646cb42 --- /dev/null +++ b/tests/unit/test_research_structured_block_context.py @@ -0,0 +1,145 @@ +"""Short key-value lines reach the model with their block; web chrome stays out.""" + +from __future__ import annotations + +import hashlib +from datetime import UTC, datetime +from pathlib import Path + +from thoth.adapters.parsers.text_parser import TextParser +from thoth.application.services.research_context_assembler import assemble_context +from thoth.application.services.research_retrieval import ( + adjacent_packet, + lexical_candidates, + text_neighbors, +) +from thoth.domain.artifact import ArtifactEnvelope, StructuralDocument +from thoth.domain.enums import ( + AuthorityState, + CutoffState, + SecurityClass, + SupportState, + VerificationState, +) +from thoth.domain.evidence import EvidenceSpan + +DEMO = Path(__file__).parents[2] / "examples" / "synthetic-radar-demo-v1" +QUESTION = "SYN-C-DET-RAIN criterion verdict is FAIL. Find causes and tests that separate them." + + +def _artifact(raw: bytes, name: str, artifact_id: str) -> ArtifactEnvelope: + return ArtifactEnvelope( + artifact_id=artifact_id, + project_id="project:1", + source_uri=name, + media_type="text/plain", + byte_sha256=hashlib.sha256(raw).hexdigest(), + authority=AuthorityState.INFORMAL, + cutoff_state=CutoffState.ELIGIBLE, + security_class=SecurityClass.INTERNAL, + retrieved_at=datetime.now(UTC), + parser_name="unparsed", + parser_version="0", + ) + + +def _spans(document: StructuralDocument, prefix: str) -> tuple[EvidenceSpan, ...]: + # Same construction as ingestion: the span keeps the node's locator plus its node id. + return tuple( + EvidenceSpan( + span_id=f"span:{prefix}:{index}", + project_id="project:1", + artifact_id=document.artifact.artifact_id, + source_version_id=f"version:{prefix}", + locator=node.locator.model_copy(update={"structural_node_id": node.node_id}), + exact_text=node.text or "", + text_sha256=hashlib.sha256((node.text or "").encode()).hexdigest(), + extraction_method="text:test", + support_state=SupportState.EXTRACTED, + authority_state=AuthorityState.INFORMAL, + verification_state=VerificationState.SCHEMA_VALID, + cutoff_state=CutoffState.ELIGIBLE, + ) + for index, node in enumerate(document.nodes) + if node.text + ) + + +def _parse(raw: bytes, name: str, artifact_id: str) -> StructuralDocument: + return TextParser().parse(_artifact(raw, name, artifact_id), raw) + + +def _demo() -> tuple[StructuralDocument, tuple[EvidenceSpan, ...]]: + path = DEMO / "31_RESULT_RAIN_SYNTHETIC.yaml" + document = _parse(path.read_bytes(), path.name, "artifact:rain") + return document, _spans(document, "rain") + + +def _by_text(spans: tuple[EvidenceSpan, ...], text: str) -> EvidenceSpan: + return next(span for span in spans if span.exact_text == text) + + +class _Ledger: + def __init__(self, document: StructuralDocument) -> None: + self.document = document + + def read_structure( + self, project_id: str, artifact_id: str, source_version_id: str + ) -> StructuralDocument | None: + return self.document + + +def test_neighbors_of_a_key_value_line_include_its_whole_block() -> None: + _, spans = _demo() + anchor = _by_text(spans, "criterion: SYN-C-DET-RAIN") + texts = {span.exact_text for span in text_neighbors(anchor, spans)} + assert {"detection:", "numerator: 16", "denominator: 20", "unit: ratio"} <= texts + assert 'value: "0.80"' in texts + # The sibling block of another key is not pulled in. + assert "criterion: SYN-C-FA-RAIN" not in texts + + +def test_a_very_large_block_keeps_only_the_adjacent_lines() -> None: + rows = "".join(f" item_{i}: {i}\n" for i in range(40)) + document = _parse(("big:\n" + rows).encode(), "big.yaml", "artifact:big") + spans = _spans(document, "big") + anchor = _by_text(spans, "item_20: 20") + texts = {span.exact_text for span in text_neighbors(anchor, spans)} + assert texts == {"big:", "item_19: 19", "item_20: 20", "item_21: 21"} + + +def test_short_key_value_lines_are_candidates_when_the_question_names_them() -> None: + _, spans = _demo() + candidates = lexical_candidates("What was the denominator?", (), spans) + assert "denominator: 20" in {span.exact_text for span in candidates} + + +def test_web_chrome_fragments_are_still_filtered() -> None: + page = ( + b"---\n" + b"article\n" + b"Share\n" + b"* [](https://example.com/share)\n" + b"https://www.facebook.com/sharer.php?u=1 latency link\n" + b"The measured latency was 12 ms on the reference platform in the lab.\n" + ) + spans = _spans(_parse(page, "page.md", "artifact:page"), "page") + candidates = lexical_candidates("latency 12 ms share article", (), spans) + assert {span.exact_text for span in candidates} == { + "The measured latency was 12 ms on the reference platform in the lab." + } + + +def test_the_denominator_reaches_the_model_input_for_the_rain_question() -> None: + document, spans = _demo() + pinned = (_by_text(spans, 'value: "0.80"').span_id,) + candidates = lexical_candidates(QUESTION, (), spans, pinned) + assert "denominator: 20" not in {span.exact_text for span in candidates} + ranking = tuple(span.span_id for span in candidates) + + packet = adjacent_packet(ranking, candidates, spans, QUESTION) + assert "denominator: 20" in {span.exact_text for span in packet} + + assembly = assemble_context(ranking, candidates, spans, _Ledger(document)) # type: ignore[arg-type] + assert "denominator: 20" in {span.exact_text for span in assembly.evidence} + assert _by_text(assembly.evidence, "denominator: 20").locator.line == 13 diff --git a/tests/unit/test_source_manifest_git_bytes.py b/tests/unit/test_source_manifest_git_bytes.py new file mode 100644 index 0000000..d5bd44b --- /dev/null +++ b/tests/unit/test_source_manifest_git_bytes.py @@ -0,0 +1,213 @@ +"""Manifest bytes follow real Git add, including unstaged files and clean attributes.""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path +from typing import cast + +import pytest +from scripts import update_source_manifest as tool +from tests.architecture.public_source_profile import PublicPackageError, public_source_profile +from tests.unit.test_update_source_manifest import ANCHORS + +HEADER: tool.Manifest = { + "schema_version": "thoth-source-preview-manifest-v1", + "created_at": "t", + "license": "MIT", + "git_history_included": False, + "full_suite_verified": False, + "known_selected_regression_failures": 0, + "files": [], + "source_archive_sha256": "a" * 64, + "public_repository": "https://example.invalid/repo", + "snapshot_type": "MIT source-only public preview", + "verification_basis": "Synthetic Git conversion fixture only.", +} + + +def _git(root: Path, *args: str) -> bytes: + return subprocess.run(["git", "-C", str(root), *args], capture_output=True, check=True).stdout + + +def _init(root: Path) -> None: + root.mkdir(parents=True, exist_ok=True) + _git(root, "init", "-q") + _git(root, "config", "core.autocrlf", "false") + _git(root, "config", "core.safecrlf", "false") + _git(root, "config", "core.attributesFile", str(root / "no-global-attributes")) + + +def _snapshot(root: Path) -> dict[str, str]: + return { + path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*") + if path.is_file() + } + + +def _build(root: Path) -> tool.Manifest: + return tool.build_manifest(root, tool.list_public_paths(root), dict(HEADER), created_at="t") + + +def _rows(manifest: tool.Manifest) -> list[dict[str, object]]: + return cast(list[dict[str, object]], manifest["files"]) + + +def _assert_staged_bytes(root: Path, manifest: tool.Manifest) -> None: + _git(root, "add", "--all") + for row in _rows(manifest): + raw = _git(root, "show", ":" + str(row["path"])) + assert row["bytes"] == len(raw) + assert row["sha256"] == hashlib.sha256(raw).hexdigest() + + +def test_lf_and_crlf_worktrees_match_and_leave_real_git_storage_unchanged(tmp_path: Path) -> None: + manifests: list[tool.Manifest] = [] + for name, eol, autocrlf in (("lf", b"\n", "false"), ("crlf", b"\r\n", "true")): + root = tmp_path / name + _init(root) + (root / ".gitattributes").write_bytes(b"* text=auto eol=lf\n") + (root / "tracked.txt").write_bytes(b"before\n") + _git(root, "add", ".") + _git(root, "config", "core.autocrlf", autocrlf) + (root / "tracked.txt").write_bytes(b"unstaged" + eol) + (root / "new space 한글.txt").write_bytes(b"untracked" + eol) + (root / ".gitignore").write_bytes(b"ignored.txt\n") + (root / "ignored.txt").write_bytes(b"not public") + before = _snapshot(root) + manifest = _build(root) + assert tool.compare(root, manifest, tool.list_public_paths(root)).clean + assert _snapshot(root) == before + rows = {str(row["path"]): row for row in _rows(manifest)} + assert "ignored.txt" not in rows + assert rows["tracked.txt"]["sha256"] == hashlib.sha256(b"unstaged\n").hexdigest() + assert rows["new space 한글.txt"]["bytes"] == len(b"untracked\n") + _assert_staged_bytes(root, manifest) + manifests.append(manifest) + assert manifests[0] == manifests[1] + + +def test_existing_crlf_index_is_preserved_exactly_as_git_add(tmp_path: Path) -> None: + _init(tmp_path) + (tmp_path / "old.txt").write_bytes(b"old\r\n") + _git(tmp_path, "add", "old.txt") + (tmp_path / ".gitattributes").write_bytes(b"* text=auto eol=lf\n") + (tmp_path / "old.txt").write_bytes(b"edited\r\n") + manifest = _build(tmp_path) + row = next(row for row in _rows(manifest) if row["path"] == "old.txt") + assert row["sha256"] == hashlib.sha256(b"edited\r\n").hexdigest() + _assert_staged_bytes(tmp_path, manifest) + + +def test_git_attributes_cover_binary_exact_bytes_ident_encoding_and_clean_filter( + tmp_path: Path, +) -> None: + _init(tmp_path) + (tmp_path / ".gitattributes").write_bytes( + b"* text=auto eol=lf\n*.bin binary\n*.exact -text\n*.cmd text eol=crlf\n" + b"*.id ident\n*.wide text working-tree-encoding=UTF-16\n*.upper filter=upper\n" + ) + command = ( + f'"{Path(sys.executable).as_posix()}" -c ' + '"import sys;sys.stdout.buffer.write(sys.stdin.buffer.read().upper())"' + ) + _git(tmp_path, "config", "filter.upper.clean", command) + _git(tmp_path, "config", "filter.upper.required", "true") + payloads = { + "binary.bin": b"\x00\r\n\xff", + "preserve.exact": b"exact\r\n", + "run.cmd": b"echo yes\r\n", + "expanded.id": b"$Id: deadbeef $\r\n", + "utf16.wide": "wide\r\n".encode("utf-16"), + "clean.upper": b"lower\r\n", + } + for name, raw in payloads.items(): + (tmp_path / name).write_bytes(raw) + before = _snapshot(tmp_path) + clean = tool.read_source_bytes(tmp_path, list(payloads)) + assert clean == { + "binary.bin": payloads["binary.bin"], + "preserve.exact": payloads["preserve.exact"], + "run.cmd": b"echo yes\n", + "expanded.id": b"$Id$\n", + "utf16.wide": b"wide\n", + "clean.upper": b"LOWER\n", + } + assert _snapshot(tmp_path) == before + _assert_staged_bytes(tmp_path, _build(tmp_path)) + + +def test_required_clean_failure_does_not_fall_back_or_modify_repository(tmp_path: Path) -> None: + _init(tmp_path) + (tmp_path / ".gitattributes").write_bytes(b"*.txt filter=broken\n") + (tmp_path / "file.txt").write_bytes(b"not silently accepted\r\n") + _git(tmp_path, "config", "filter.broken.clean", "exit 1") + _git(tmp_path, "config", "filter.broken.required", "true") + before = _snapshot(tmp_path) + with pytest.raises(subprocess.CalledProcessError): + _build(tmp_path) + assert _snapshot(tmp_path) == before + + +def test_check_mode_reports_real_content_drift_without_writing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _init(tmp_path) + (tmp_path / ".gitattributes").write_bytes(b"* text=auto eol=lf\n") + (tmp_path / "text.txt").write_bytes(b"first\r\n") + (tmp_path / tool.MANIFEST_NAME).write_bytes(tool.render(_build(tmp_path))) + before = _snapshot(tmp_path) + assert tool.main(["--check"], root=tmp_path) == 0 + assert _snapshot(tmp_path) == before + (tmp_path / "text.txt").write_bytes(b"changed\r\n") + before = _snapshot(tmp_path) + assert tool.main(["--check"], root=tmp_path) == 1 + assert _snapshot(tmp_path) == before + output = capsys.readouterr().out + assert "changed: 1" in output and tool.REFRESH_HINT in output + + +def test_public_checker_uses_clean_bytes_and_rejects_content_drift(tmp_path: Path) -> None: + _init(tmp_path) + for relative, text in ANCHORS.items(): + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(text.encode("utf-8")) + (tmp_path / ".gitattributes").write_bytes(b"* text=auto eol=lf\n") + (tmp_path / "b.txt").write_bytes(b"second\r\n") + (tmp_path / tool.MANIFEST_NAME).write_bytes(tool.render(_build(tmp_path))) + before = _snapshot(tmp_path) + profile = public_source_profile(tmp_path) + assert profile is not None and profile.read_bytes("b.txt") == b"second\n" + assert _snapshot(tmp_path) == before + (tmp_path / "b.txt").write_bytes(b"edited\r\n") + with pytest.raises(PublicPackageError, match="bytes differ"): + profile.read_bytes("b.txt") + + +def test_export_without_git_keeps_raw_bytes_even_inside_a_repository(tmp_path: Path) -> None: + _init(tmp_path) + root = tmp_path / "export" + root.mkdir() + (root / ".gitattributes").write_bytes(b"* text eol=lf\n") + (root / "file.txt").write_bytes(b"raw\r\n") + assert tool.read_source_bytes(root, ["file.txt"]) == {"file.txt": b"raw\r\n"} + + +def test_git_directory_pointer_and_split_index_are_preserved(tmp_path: Path) -> None: + root = tmp_path / "worktree" + _init(root) + (root / ".gitattributes").write_bytes(b"* text eol=lf\n") + (root / "file.txt").write_bytes(b"before\n") + _git(root, "add", ".") + _git(root, "update-index", "--split-index") + metadata = tmp_path / "metadata" + (root / ".git").rename(metadata) + (root / ".git").write_bytes(f"gitdir: {metadata.as_posix()}\n".encode()) + (root / "file.txt").write_bytes(b"after\r\n") + before = _snapshot(tmp_path) + assert tool.read_source_bytes(root, ["file.txt"]) == {"file.txt": b"after\n"} + assert _snapshot(tmp_path) == before diff --git a/tests/unit/test_text_parser_key_paths.py b/tests/unit/test_text_parser_key_paths.py new file mode 100644 index 0000000..a67feda --- /dev/null +++ b/tests/unit/test_text_parser_key_paths.py @@ -0,0 +1,85 @@ +"""A YAML file's indented lines keep their key path so a block can be read together.""" + +from __future__ import annotations + +import hashlib +from datetime import UTC, datetime +from pathlib import Path + +from thoth.adapters.parsers.text_parser import TextParser +from thoth.domain.artifact import ArtifactEnvelope +from thoth.domain.enums import AuthorityState, CutoffState, SecurityClass + +_YAML = b"""# comment line +notice: demo +detection: + criterion: SYN-C-DET + numerator: 16 + denominator: 20 +false_track: + criterion: SYN-C-FA + nested: + deeper: 1 + event_ids: + - SYN-FT-1 + - SYN-FT-2 +""" + + +def _artifact(raw: bytes, name: str) -> ArtifactEnvelope: + return ArtifactEnvelope( + artifact_id="artifact:text", + project_id="project:1", + source_uri=name, + media_type="text/plain", + byte_sha256=hashlib.sha256(raw).hexdigest(), + authority=AuthorityState.INFORMAL, + cutoff_state=CutoffState.ELIGIBLE, + security_class=SecurityClass.INTERNAL, + retrieved_at=datetime.now(UTC), + parser_name="unparsed", + parser_version="0", + ) + + +def _pointers(raw: bytes, name: str) -> dict[str, str | None]: + document = TextParser().parse(_artifact(raw, name), raw) + return {node.text or "": node.locator.json_pointer for node in document.nodes} + + +def test_yaml_lines_carry_their_key_path() -> None: + pointers = _pointers(_YAML, "result.yaml") + assert pointers["notice: demo"] == "/notice" + assert pointers["detection:"] == "/detection" + assert pointers["denominator: 20"] == "/detection/denominator" + assert pointers["criterion: SYN-C-FA"] == "/false_track/criterion" + assert pointers["deeper: 1"] == "/false_track/nested/deeper" + first, second = pointers["- SYN-FT-1"], pointers["- SYN-FT-2"] + assert first is not None and first.startswith("/false_track/event_ids/") + assert first != second + + +def test_comment_lines_and_markdown_files_get_no_key_path() -> None: + assert _pointers(_YAML, "result.yaml")["# comment line"] is None + markdown = _pointers(b"Note: this is prose\n indented: line\n", "notes.md") + assert set(markdown.values()) == {None} + + +def test_every_line_stays_its_own_node_in_order() -> None: + document = TextParser().parse(_artifact(_YAML, "result.yml"), _YAML) + assert [node.text for node in document.nodes] == [ + line.strip() for line in _YAML.decode().splitlines() if line.strip() + ] + assert [node.locator.line for node in document.nodes][:3] == [1, 2, 3] + + +def test_demo_result_file_denominator_has_its_block_path() -> None: + path = ( + Path(__file__).parents[2] + / "examples" + / "synthetic-radar-demo-v1" + / "31_RESULT_RAIN_SYNTHETIC.yaml" + ) + pointers = _pointers(path.read_bytes(), path.name) + assert pointers["denominator: 20"] == "/detection/denominator" + assert pointers['value: "0.80"'] == "/detection/value" diff --git a/tests/unit/test_trace_origin_context.py b/tests/unit/test_trace_origin_context.py index d2931d3..54a2b0b 100644 --- a/tests/unit/test_trace_origin_context.py +++ b/tests/unit/test_trace_origin_context.py @@ -67,6 +67,14 @@ def test_a_valid_origin_goes_to_the_context_and_pins_its_source_positions() -> N assert item.pinned_spans == ("span:0",) +def test_the_siblings_source_positions_are_pinned_after_the_rows_own_without_repeats() -> None: + item = work() + apply_trace_origin( + item, {"origin": origin(comparison_span_refs=("span:1", "span:0", "span:2"))}, "p" + ) + assert item.pinned_spans == ("span:0", "span:1", "span:2") + + def test_no_origin_leaves_the_work_as_it_was() -> None: item = work() apply_trace_origin(item, {}, "p")