From 86e42721058de43bbb3b47fd2222e13ad80e2438 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Wed, 21 Oct 2026 03:47:24 +0000 Subject: [PATCH 1/5] feat(agents): add shared measurement context adapter --- .../shared_measurement_context.py | 211 +++++ .../tests/test_shared_measurement_context.py | 758 ++++++++++++++++++ 2 files changed, 969 insertions(+) create mode 100644 packages/agents/src/issuedigest_agents/shared_measurement_context.py create mode 100644 packages/agents/tests/test_shared_measurement_context.py diff --git a/packages/agents/src/issuedigest_agents/shared_measurement_context.py b/packages/agents/src/issuedigest_agents/shared_measurement_context.py new file mode 100644 index 00000000..2d933dbe --- /dev/null +++ b/packages/agents/src/issuedigest_agents/shared_measurement_context.py @@ -0,0 +1,211 @@ +"""Pure shared measurement roles; exact linkage never certifies meaning or access.""" + +from typing import Annotated, Any, Literal, Self, get_args, get_origin + +from pydantic import BaseModel, Field, model_validator + +from .contextual_anchor import ContextualAnchor, resolve_contextual_anchor +from .contextual_frame import ContextualFrameRelationInput +from .measurement_scope import ( + MeasurementFactInput, + MeasurementFrameInput, + MeasurementScopeFrame, + MeasurementScopeInput, + build_measurement_scope_frame, +) +from .numeric_candidates import build_numeric_candidates +from .numeric_evidence import Source, _source_data, _StrictModel +from .numeric_frame import ReportingTense +from .numeric_selection import FactKind, ResolvedAnchor +from .numeric_selection_evaluation import _canonical_models, _model_types, _read + +type SharedId = Annotated[str, Field(min_length=1, max_length=64, pattern=r"\A[A-Za-z0-9_-]+\z")] + + +class SharedMeasurementContextError(ValueError): + """Sanitized whole-operation failure without source, validation details or partial output.""" + + +class SharedMeasurementContext(_StrictModel): + context_id: SharedId + reporting_event_id: SharedId + measured_variable: ContextualAnchor | None + target_scopes: tuple[MeasurementScopeInput, ...] = Field(max_length=4) + period: ContextualAnchor | None + value_status: FactKind + logical_condition: ContextualAnchor | None + purpose: ContextualAnchor | None + forecast_basis: ContextualAnchor | None + comparison_baseline: ContextualAnchor | None + + +class SharedReportingEvent(_StrictModel): + event_id: SharedId + kind: Literal["attributed_report", "narration", "unknown"] + reporting_actor: ContextualAnchor | None + predicate: ContextualAnchor | None + reporting_tense: ReportingTense + + @model_validator(mode="after") + def explicit_reporting_roles(self) -> Self: + if self.kind == "attributed_report": + if self.reporting_actor is None or self.predicate is None: + raise ValueError("Attributed report needs actor and predicate") + elif self.kind == "narration": + if self.reporting_actor is not None or self.predicate is None: + raise ValueError("Narration needs predicate without actor") + elif ( + self.reporting_actor is not None + or self.predicate is not None + or self.reporting_tense != "unknown" + ): + raise ValueError("Unknown event cannot assert reporting roles or tense") + return self + + +class SharedNumericBinding(_StrictModel): + candidate_id: SharedId + context_id: SharedId + target_scopes: tuple[MeasurementScopeInput, ...] = Field(max_length=4) + status_evidence_sentence_id: Annotated[str, Field(min_length=1, max_length=1000)] | None + + +class SharedMeasurementInput(_StrictModel): + contexts: tuple[SharedMeasurementContext, ...] = Field(max_length=12) + events: tuple[SharedReportingEvent, ...] = Field(max_length=12) + bindings: tuple[SharedNumericBinding, ...] = Field(max_length=12) + relations: tuple[ContextualFrameRelationInput, ...] = Field(max_length=16) + + +class ResolvedSharedPurpose(_StrictModel): + context_id: SharedId + purpose: ResolvedAnchor | None + + +class SharedMeasurementFrame(_StrictModel): + """Original shared input and all derived roles, with no semantic approval implied.""" + + profile: Literal["shared-measurement-context-v1"] = "shared-measurement-context-v1" + status: Literal["unreviewed"] = "unreviewed" + input: SharedMeasurementInput + frame: MeasurementScopeFrame + purposes: tuple[ResolvedSharedPurpose, ...] = Field(max_length=12) + + +def _actual_models(value: Any, annotation: Any) -> None: + # The existing canonical guard rejects subclasses/extras before any serialization. + # Also reject dictionaries/lists installed into actual models by unchecked copies. + if isinstance(value, BaseModel): + for name, child in value.__dict__.items(): + _actual_models(child, type(value).model_fields[name].annotation) + elif isinstance(value, (tuple, list)): + if get_origin(annotation) is not tuple or type(value) is not tuple: + raise ValueError("Expected actual tuple") + args = get_args(annotation) + for i, child in enumerate(value): + _actual_models(child, args[0] if args[-1] is Ellipsis else args[i]) + elif value is not None and _model_types(annotation): + raise ValueError("Expected actual nested model") + + +def _build(source: Source, data: SharedMeasurementInput) -> SharedMeasurementFrame: + index = build_numeric_candidates(source) + candidates = {candidate.candidate_id: candidate for candidate in index.candidates} + sentences = {sentence.sentence_id: sentence for sentence in index.sentences} + contexts = {context.context_id: context for context in data.contexts} + events = {event.event_id: event for event in data.events} + if len(contexts) != len(data.contexts) or len(events) != len(data.events): + raise ValueError("Duplicate shared record ID") + if {binding.context_id for binding in data.bindings} != contexts.keys(): + raise ValueError("Missing or unused context") + if {context.reporting_event_id for context in data.contexts} != events.keys(): + raise ValueError("Missing or unused reporting event") + if len({binding.candidate_id for binding in data.bindings}) != len(data.bindings): + raise ValueError("Duplicate candidate binding") + purposes = tuple( + ResolvedSharedPurpose( + context_id=context.context_id, + purpose=resolve_contextual_anchor(source, context.purpose).resolved + if context.purpose is not None + else None, + ) + for context in data.contexts + ) + purpose_by_id = {purpose.context_id: purpose.purpose for purpose in purposes} + facts: list[MeasurementFactInput] = [] + for binding in data.bindings: + context = contexts[binding.context_id] + event = events[context.reporting_event_id] + candidate = candidates[binding.candidate_id] + purpose = purpose_by_id[context.context_id] + if purpose is not None and purpose.span.citation_id != candidate.expression.citation_id: + raise ValueError("Purpose outside candidate quote") + status_text = None + if binding.status_evidence_sentence_id is not None: + sentence = sentences[binding.status_evidence_sentence_id] + if ( + sentence.sentence_id not in candidate.sentence_ids + or sentence.span.citation_id != candidate.expression.citation_id + ): + raise ValueError("Wrong candidate status sentence") + # ContextualAnchor enforces exact nonblank text of 1..1000 code points. + status_text = ContextualAnchor( + sentence_id=sentence.sentence_id, text=sentence.span.text, context_text=None + ) + facts.append( + MeasurementFactInput( + candidate_id=binding.candidate_id, + measured_entity=context.measured_variable, + reporting_actor=event.reporting_actor, + period=context.period, + value_status=context.value_status, + logical_condition=context.logical_condition, + forecast_basis=context.forecast_basis, + comparison_baseline=context.comparison_baseline, + status_text=status_text, + reporting_tense=event.reporting_tense, + reporting_tense_text=event.predicate, + target_scopes=context.target_scopes + binding.target_scopes, + ) + ) + # Preserve every direct relation and reuse frozen statement/cue/reference/cycle checks. + frame = build_measurement_scope_frame( + source, MeasurementFrameInput(facts=tuple(facts), relations=data.relations) + ) + return SharedMeasurementFrame(input=data, frame=frame, purposes=purposes) + + +def build_shared_measurement_frame( + source: Source, input: SharedMeasurementInput | dict[str, Any] +) -> SharedMeasurementFrame: + """Expand only explicitly selected shared records, or reject the entire canonical input.""" + try: + _canonical_models(source, Source) + _canonical_models(input, SharedMeasurementInput) + _actual_models(source, Source) + if isinstance(input, BaseModel): + _actual_models(input, SharedMeasurementInput) + _source_data(source) + return _build(source, _read(SharedMeasurementInput, input)) + except Exception: + raise SharedMeasurementContextError("Invalid shared measurement input") from None + + +def validate_shared_measurement_frame( + source: Source, result: SharedMeasurementFrame | dict[str, Any] +) -> SharedMeasurementFrame: + """Rebuild the entire wrapper from current source, including separately resolved purposes.""" + try: + _canonical_models(source, Source) + _canonical_models(result, SharedMeasurementFrame) + _actual_models(source, Source) + if isinstance(result, BaseModel): + _actual_models(result, SharedMeasurementFrame) + _source_data(source) + checked = _read(SharedMeasurementFrame, result) + expected = _build(source, checked.input) + if checked != expected: + raise ValueError("Shared frame differs from current source reconstruction") + return expected + except Exception: + raise SharedMeasurementContextError("Invalid shared measurement frame") from None diff --git a/packages/agents/tests/test_shared_measurement_context.py b/packages/agents/tests/test_shared_measurement_context.py new file mode 100644 index 00000000..f8519f51 --- /dev/null +++ b/packages/agents/tests/test_shared_measurement_context.py @@ -0,0 +1,758 @@ +"""Synthetic shared roles test structure and provenance, never semantic approval.""" + +import builtins +import importlib +import importlib.util +import io +import json +import os +import socket +import sys +from copy import deepcopy +from pathlib import Path +from uuid import uuid4 + +import pytest +from pydantic import Field, create_model +from pydantic_ai import Agent +from pydantic_ai.models import Model +from test_numeric_evidence import document + + +@pytest.fixture(scope="module", autouse=True) +def restore_introduced_modules(): + names = ( + "shared_measurement_context", + "measurement_scope", + "contextual_frame", + "contextual_anchor", + "numeric_frame", + "numeric_selection_evaluation", + "numeric_selection", + ) + introduced = [ + f"issuedigest_agents.{n}" for n in names if f"issuedigest_agents.{n}" not in sys.modules + ] + yield + for name in introduced: + sys.modules.pop(name, None) + + +def api(): + name = "issuedigest_agents.shared_measurement_context" + assert importlib.util.find_spec(name) is not None, "Missing shared measurement contract" + return importlib.import_module(name) + + +def anchor(text, sentence="s1", context=None): + return {"sentence_id": sentence, "text": text, "context_text": context} + + +def scope(text, sentence="s1", context=None, kind="geographic"): + return {"kind": kind, "anchor": anchor(text, sentence, context)} + + +def context(context_id="c1", event_id="e1", **fields): + return { + "context_id": context_id, + "reporting_event_id": event_id, + "measured_variable": None, + "target_scopes": [], + "period": None, + "value_status": "unknown", + "logical_condition": None, + "purpose": None, + "forecast_basis": None, + "comparison_baseline": None, + **fields, + } + + +def event(event_id="e1", **fields): + return { + "event_id": event_id, + "kind": "unknown", + "reporting_actor": None, + "predicate": None, + "reporting_tense": "unknown", + **fields, + } + + +def binding(candidate="n1", context_id="c1", **fields): + return { + "candidate_id": candidate, + "context_id": context_id, + "target_scopes": [], + "status_evidence_sentence_id": None, + **fields, + } + + +def simple(text="동부 서부 이용자 10명, 20명."): + return document(text), { + "contexts": [context()], + "events": [event()], + "bindings": [binding()], + "relations": [], + } + + +def example(): + src = document( + "발표처는 알렸다. 운영기관이 안정 지원을 위해 조건 충족 시 내년 합성국 동부 청년 이용자 " + "10명과 서부 이용자 20명을 공급할 예정이다." + ) + data = { + "contexts": [ + context( + measured_variable=anchor("이용자", "s2", "청년 이용자"), + target_scopes=[scope("합성국", "s2")], + period=anchor("내년", "s2"), + value_status="planned", + logical_condition=anchor("조건 충족 시", "s2"), + purpose=anchor("안정 지원을 위해", "s2"), + ) + ], + "events": [ + event( + kind="attributed_report", + reporting_actor=anchor("발표처"), + predicate=anchor("알렸다"), + reporting_tense="past", + ) + ], + "bindings": [ + binding( + target_scopes=[scope("동부", "s2"), scope("청년", "s2", kind="population")], + status_evidence_sentence_id="s2", + ), + binding("n2", target_scopes=[scope("서부", "s2")], status_evidence_sentence_id="s2"), + ], + "relations": [ + { + "kind": "parallel", + "from_id": "n1", + "to_id": "n2", + "statement_sentence_ids": ["s2"], + "cue": anchor("과", "s2"), + } + ], + } + return src, data + + +def rejects(src, data): + mod = api() + with pytest.raises(mod.SharedMeasurementContextError) as error: + mod.build_shared_measurement_frame(src, data) + assert str(error.value) == "Invalid shared measurement input" + assert error.value.__cause__ is None + assert error.value.__suppress_context__ + + +def test_explicit_shared_context_preserves_scopes_reporting_status_and_separate_purpose(): + mod = api() + src, data = example() + result = mod.build_shared_measurement_frame(src, data) + assert (result.profile, result.status) == ("shared-measurement-context-v1", "unreviewed") + assert result.input.model_dump(mode="json") == data + facts = result.frame.frame.resolved.facts + assert [f.measured_entity.span.text for f in facts] == ["이용자", "이용자"] + assert [f.reporting_actor.span.text for f in facts] == ["발표처", "발표처"] + assert [(f.reporting_tense, f.reporting_tense_text.span.text) for f in facts] == [ + ("past", "알렸다"), + ("past", "알렸다"), + ] + assert [f.value_status for f in facts] == ["planned", "planned"] + assert [f.status_text.span.text for f in facts] == [ + " 운영기관이 안정 지원을 위해 조건 충족 시 내년 합성국 동부 청년 이용자 " + "10명과 서부 이용자 20명을 공급할 예정이다." + ] * 2 + assert [f.logical_condition.span.text for f in facts] == ["조건 충족 시"] * 2 + assert [(p.context_id, p.purpose.span.text) for p in result.purposes] == [ + ("c1", "안정 지원을 위해") + ] + assert [[s.anchor.span.text for s in f.target_scopes] for f in result.frame.scopes] == [ + ["합성국", "동부", "청년"], + ["합성국", "서부"], + ] + assert result.frame.frame.resolved.relations[0].cue.span.text == "과" + assert mod.validate_shared_measurement_frame(src, result) == result + assert ( + mod.validate_shared_measurement_frame(src, json.loads(result.model_dump_json())) == result + ) + assert mod.build_shared_measurement_frame(src, result.input) == result + + +def test_selected_context_only_and_input_ordered_nullable_purposes(): + src, data = simple("운영기관이 동부 이용자 10명, 서부 이용자 20명을 운영한다.") + data["contexts"] = [ + context("c2", measured_variable=anchor("이용자", context="서부 이용자")), + context("c1", purpose=anchor("동부")), + ] + data["bindings"] = [binding(), binding("n2", "c2")] + data["events"] = [ + event(kind="narration", predicate=anchor("운영한다"), reporting_tense="present") + ] + result = api().build_shared_measurement_frame(src, data) + assert result.frame.frame.resolved.facts[0].measured_entity is None + assert result.frame.frame.resolved.facts[0].logical_condition is None + assert all(f.reporting_actor is None for f in result.frame.frame.resolved.facts) + assert [(p.context_id, p.purpose is None) for p in result.purposes] == [ + ("c2", True), + ("c1", False), + ] + + +def test_same_purpose_and_condition_location_is_preserved_without_semantic_repair(): + src, data = simple() + data["contexts"][0].update(purpose=anchor("동부"), logical_condition=anchor("동부")) + result = api().build_shared_measurement_frame(src, data) + assert result.purposes[0].purpose == result.frame.frame.resolved.facts[0].logical_condition + assert result.status == "unreviewed" + + +def test_empty_full_input_is_valid_with_or_without_numeric_candidates(): + for text in ("수치가 없다.", "이용자 10명."): + result = api().build_shared_measurement_frame( + document(text), {"contexts": [], "events": [], "bindings": [], "relations": []} + ) + assert result.purposes == result.frame.scopes == result.frame.frame.resolved.facts == () + + +@pytest.mark.parametrize("field", ["contexts", "events", "bindings", "relations"]) +def test_each_collection_over_cap_rejects(field): + src, data = example() + data[field] = [data[field][0]] * (17 if field == "relations" else 13) + rejects(src, data) + + +@pytest.mark.parametrize("field", ["contexts", "bindings"]) +def test_individual_scope_cap_and_combined_scope_cap_reject(field): + src, data = simple("A B C D E 이용자 10명.") + data[field][0]["target_scopes"] = [scope(t) for t in "ABCDE"] + rejects(src, data) + data[field][0]["target_scopes"] = [scope(t) for t in "ABCD"] + other = "contexts" if field == "bindings" else "bindings" + data[other][0]["target_scopes"] = [scope("E")] + rejects(src, data) + + +def test_all_collection_and_combined_scope_limits_are_accepted(): + src = document( + "A B C D 전체 " + ", ".join(f"항목{chr(97 + i)} {10 + i}명" for i in range(12)) + " 포함." + ) + ids = [f"n{i + 1}" for i in range(12)] + pairs = [(a, b) for i, a in enumerate(ids) for b in ids[i + 1 :]][:16] + result = api().build_shared_measurement_frame( + src, + { + "contexts": [ + context( + f"c{i}", + f"e{i}", + measured_variable=anchor("전체"), + target_scopes=[scope("A"), scope("B")], + ) + for i in range(12) + ], + "events": [event(f"e{i}") for i in range(12)], + "bindings": [ + binding(f"n{i + 1}", f"c{i}", target_scopes=[scope("C"), scope("D")]) + for i in range(12) + ], + "relations": [ + { + "kind": "parallel", + "from_id": a, + "to_id": b, + "statement_sentence_ids": ["s1"], + "cue": anchor("포함"), + } + for a, b in pairs + ], + }, + ) + assert len(result.purposes) == len(result.frame.scopes) == 12 + assert len(result.frame.frame.resolved.relations) == 16 + assert all(len(f.target_scopes) == 4 for f in result.frame.scopes) + + +@pytest.mark.parametrize( + "mutation", + [ + "duplicate_context", + "duplicate_event", + "duplicate_binding", + "missing_context", + "missing_event", + "missing_candidate", + "unused_context", + "unused_event", + ], +) +def test_duplicate_missing_and_unused_references_reject(mutation): + src, data = simple() + if mutation.startswith("duplicate_"): + group = {"context": "contexts", "event": "events", "binding": "bindings"}[mutation[10:]] + data[group].append(deepcopy(data[group][0])) + elif mutation == "missing_context": + data["bindings"][0]["context_id"] = "absent" + elif mutation == "missing_event": + data["contexts"][0]["reporting_event_id"] = "absent" + elif mutation == "missing_candidate": + data["bindings"][0]["candidate_id"] = "n999" + elif mutation == "unused_context": + data["contexts"].append(context("unused")) + else: + data["events"].append(event("unused")) + rejects(src, data) + + +@pytest.mark.parametrize("bad", ["", "a" * 65, "지역", "a b", "a.b", "a\n", True, 1]) +@pytest.mark.parametrize( + "group,field", + [ + ("contexts", "context_id"), + ("contexts", "reporting_event_id"), + ("events", "event_id"), + ("bindings", "context_id"), + ("bindings", "candidate_id"), + ], +) +def test_ids_are_exact_bounded_ascii(group, field, bad): + src, data = simple() + data[group][0][field] = bad + rejects(src, data) + + +@pytest.mark.parametrize( + "fields", + [ + {"kind": "attributed_report"}, + {"kind": "attributed_report", "reporting_actor": anchor("동부")}, + {"kind": "narration"}, + {"kind": "narration", "predicate": anchor("동부"), "reporting_actor": anchor("서부")}, + {"predicate": anchor("동부")}, + {"reporting_actor": anchor("동부")}, + {"reporting_tense": "past"}, + {"kind": "invalid"}, + ], +) +def test_reporting_event_consistency_rejects(fields): + src, data = simple() + data["events"] = [event(**fields)] + rejects(src, data) + + +@pytest.mark.parametrize("status", ["observed", "planned", "forecast"]) +def test_known_status_requires_candidate_whole_sentence_and_accepts_valid_sentence(status): + src, data = simple("동부 이용자 10명. 다른 문장 20명.") + data["contexts"][0]["value_status"] = status + rejects(src, data) + for invalid in ("s2", "s999", "", "s1 ", True): + data["bindings"][0]["status_evidence_sentence_id"] = invalid + rejects(src, data) + data["bindings"][0]["status_evidence_sentence_id"] = "s1" + result = api().build_shared_measurement_frame(src, data) + assert result.frame.frame.resolved.facts[0].status_text.span.text == "동부 이용자 10명." + + +def test_unknown_status_allows_valid_sentence_but_rejects_ineligible_long_sentence(): + src, data = simple() + data["bindings"][0]["status_evidence_sentence_id"] = "s1" + assert api().build_shared_measurement_frame(src, data).frame.input.facts[ + 0 + ].status_text.text == ("동부 서부 이용자 10명, 20명.") + rejects(document("가" * 1000 + " 10명."), data) + + +@pytest.mark.parametrize( + "role", + [ + "measured_variable", + "period", + "logical_condition", + "purpose", + "forecast_basis", + "comparison_baseline", + "common_scope", + "additional_scope", + "reporting_actor", + "predicate", + "status", + ], +) +def test_all_anchor_roles_must_share_each_bound_candidates_quote(role): + src, data = simple() + src = document("동부 이용자 10명.", "서부 운영 20명.") + other = anchor("서부", "s2") + if role == "common_scope": + data["contexts"][0]["target_scopes"] = [scope("서부", "s2")] + elif role == "additional_scope": + data["bindings"][0]["target_scopes"] = [scope("서부", "s2")] + elif role in ("reporting_actor", "predicate"): + data["events"] = [ + event( + kind="attributed_report", reporting_actor=anchor("동부"), predicate=anchor("이용자") + ) + ] + data["events"][0][role] = other + elif role == "status": + data["bindings"][0]["status_evidence_sentence_id"] = "s2" + else: + data["contexts"][0][role] = other + rejects(src, data) + + +def test_scopes_duplicate_locations_reject_across_groups_but_distinct_locations_are_valid(): + src, data = simple("동부 왼쪽 동부 오른쪽 10명.") + data["contexts"][0]["target_scopes"] = [scope("동부", context="동부 왼쪽")] + data["bindings"][0]["target_scopes"] = [scope("동부", context="동부 왼쪽", kind="other")] + rejects(src, data) + data["bindings"][0]["target_scopes"] = [scope("동부", context="동부 오른쪽")] + result = api().build_shared_measurement_frame(src, data) + assert [s.anchor.span.start for s in result.frame.scopes[0].target_scopes] == [0, 6] + + +@pytest.mark.parametrize( + "text,selected", + [ + ("동부 동부 10명.", anchor("동부")), + ("aaaa 10명.", anchor("a", context="aa")), + ("A 10명. B 20명.", anchor("명", context="명. B")), + ("e\u0301 10명.", anchor("é")), + ], +) +def test_exact_contextual_purpose_rejects_ambiguous_overlap_cross_sentence_normalization( + text, selected +): + src, data = simple(text) + data["contexts"][0]["purpose"] = selected + rejects(src, data) + + +@pytest.mark.parametrize( + "mutation", + [ + "missing_endpoint", + "duplicate_pair", + "cycle", + "no_object", + "partial_statement", + "ambiguous_cue", + ], +) +def test_existing_relation_guards_are_reused_without_repair(mutation): + src, data = example() + rel = data["relations"][0] + if mutation == "missing_endpoint": + rel["to_id"] = "n999" + elif mutation == "duplicate_pair": + data["relations"].append({**rel, "kind": "unknown", "from_id": "n2", "to_id": "n1"}) + elif mutation == "cycle": + data["relations"] = [ + {**rel, "kind": "included_in"}, + {**rel, "kind": "included_in", "from_id": "n2", "to_id": "n1"}, + ] + elif mutation == "no_object": + data["contexts"][0]["measured_variable"] = None + elif mutation == "partial_statement": + rel["statement_sentence_ids"] = ["s1"] + else: + src = document( + "발표처는 알렸다. 운영기관이 안정 지원을 위해 조건 충족 시 " + "내년 합성국 동부 청년 이용자 " + "10명과 서부 이용자 20명을 공급할 예정이다 과." + ) + rejects(src, data) + + +def update_path(root, path, value): + target = root + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + + +@pytest.mark.parametrize( + "path,value", + [ + (("profile",), "forged"), + (("status",), "reviewed"), + (("input", "contexts", 0, "purpose"), None), + (("input", "events", 0, "reporting_tense"), "present"), + (("input", "bindings", 0, "context_id"), "c999"), + (("purposes", 0, "context_id"), "c999"), + (("purposes", 0, "purpose"), None), + (("purposes", 0, "purpose", "span", "start"), 0), + (("frame", "scopes", 0, "target_scopes", 0, "kind"), "population"), + (("frame", "input", "facts", 0, "reporting_tense"), "present"), + (("frame", "frame", "resolved", "facts", 0, "value_status"), "observed"), + (("frame", "frame", "resolved", "candidate_index", "source_sha256"), "0" * 64), + (("frame", "frame", "resolved", "relations", 0, "kind"), "unknown"), + (("secret",), "sensitive"), + ], +) +def test_entire_wrapper_reconstruction_rejects_tampering(path, value): + src, data = example() + raw = api().build_shared_measurement_frame(src, data).model_dump(mode="json") + update_path(raw, path, value) + with pytest.raises( + api().SharedMeasurementContextError, match="^Invalid shared measurement frame$" + ): + api().validate_shared_measurement_frame(src, raw) + + +def replace_model(root, path, replacement): + if not path: + return replacement(root) + key, *rest = path + if isinstance(key, int): + values = list(root) + values[key] = replace_model(values[key], rest, replacement) + return tuple(values) + return root.model_copy(update={key: replace_model(getattr(root, key), rest, replacement)}) + + +INPUT_NODES = [ + (), + ("contexts", 0), + ("contexts", 0, "purpose"), + ("contexts", 0, "target_scopes", 0), + ("contexts", 0, "target_scopes", 0, "anchor"), + ("events", 0), + ("events", 0, "predicate"), + ("bindings", 0), + ("bindings", 0, "target_scopes", 0), + ("relations", 0), + ("relations", 0, "cue"), +] +RESULT_NODES = [ + (), + ("purposes", 0), + ("purposes", 0, "purpose"), + ("frame",), + ("frame", "frame", "resolved"), + ("frame", "frame", "resolved", "candidate_index"), + ("frame", "frame", "resolved", "facts", 0), + ("frame", "scopes", 0, "target_scopes", 0, "anchor", "span"), +] + + +def forged(item, kind): + if kind in ("subclass", "malicious_dump"): + cls = create_model("Hidden", __base__=type(item), secret=(str, Field(exclude=True))) + result = cls(**item.model_dump(), secret="sensitive") + if kind == "malicious_dump": + + def forbidden(*args, **kwargs): + pytest.fail("Noncanonical dump was called") + + cls.model_dump = forbidden + cls.model_dump_json = forbidden + return result + if kind == "dict": + return item.model_dump() + result = item.model_copy() + if kind == "extra": + result.__dict__["secret"] = "sensitive" + elif kind == "pydantic_extra": + object.__setattr__(result, "__pydantic_extra__", {"secret": "sensitive"}) + else: + name = next(iter(type(item).model_fields)) + result = type(item).model_construct(**{**item.__dict__, name: True}) + return result + + +FORGERIES = ["subclass", "malicious_dump", "extra", "pydantic_extra", "construct", "dict"] + + +@pytest.mark.parametrize( + "path,kind", [(p, k) for p in INPUT_NODES for k in FORGERIES if p or k != "dict"] +) +def test_actual_input_models_checked_before_any_dump(path, kind): + src, data = example() + result = api().build_shared_measurement_frame(src, data) + bad = replace_model(result.input, path, lambda item: forged(item, kind)) + rejects(src, bad) + with pytest.raises(api().SharedMeasurementContextError): + api().validate_shared_measurement_frame(src, result.model_copy(update={"input": bad})) + + +@pytest.mark.parametrize( + "path,kind", [(p, k) for p in RESULT_NODES for k in FORGERIES if p or k != "dict"] +) +def test_actual_output_models_checked_before_any_dump(path, kind): + src, data = example() + result = api().build_shared_measurement_frame(src, data) + bad = replace_model(result, path, lambda item: forged(item, kind)) + with pytest.raises(api().SharedMeasurementContextError): + api().validate_shared_measurement_frame(src, bad) + + +@pytest.mark.parametrize( + "mutation", + [ + "snapshot", + "workspace", + "quote", + "source_list", + "nested_dict", + "nested_subclass", + "nested_extra", + "nested_malicious_dump", + ], +) +def test_current_source_drift_and_noncanonical_actual_source_reject(mutation): + src, data = example() + result = api().build_shared_measurement_frame(src, data) + sid, bundle, presentation = src + if mutation == "snapshot": + bad = (uuid4(), bundle, presentation) + elif mutation == "workspace": + bad = (sid, bundle, presentation.model_copy(update={"workspace_id": uuid4()})) + elif mutation == "quote": + bad = document("다른 자료 10명.") + elif mutation == "source_list": + bad = list(src) + else: + selector = forged(bundle.citations[0].selector, mutation.removeprefix("nested_")) + citation = bundle.citations[0].model_copy(update={"selector": selector}) + bad = (sid, bundle.model_copy(update={"citations": (citation,)}), presentation) + rejects(bad, data) + with pytest.raises(api().SharedMeasurementContextError): + api().validate_shared_measurement_frame(bad, result) + + +@pytest.mark.parametrize("group", [None, "contexts", "events", "bindings"]) +def test_every_declared_input_field_is_required(group): + src, data = simple() + row = data if group is None else data[group][0] + for field in tuple(row): + broken = deepcopy(data) + del (broken if group is None else broken[group][0])[field] + rejects(src, broken) + + +@pytest.mark.parametrize( + "path,value", + [ + (("contexts",), None), + (("contexts",), {}), + (("bindings", 0, "target_scopes"), None), + (("contexts", 0, "purpose"), 1), + (("contexts", 0, "value_status"), "conditional_forecast"), + (("bindings", 0, "measured_variable"), None), + (("contexts", 0, "secret"), "sensitive"), + ], +) +def test_strict_raw_containers_scalars_and_extras_reject(path, value): + src, data = simple() + update_path(data, path, value) + rejects(src, data) + + +@pytest.mark.parametrize("role", ["purpose", "reporting_actor", "predicate"]) +def test_shared_anchor_quote_checked_for_second_binding_too(role): + src = document("발표처는 알렸다. 목적을 위한 이용자 10명.", "별도 이용자 20명.") + data = { + "contexts": [context()], + "events": [event()], + "bindings": [binding(), binding("n2")], + "relations": [], + } + if role == "purpose": + data["contexts"][0]["purpose"] = anchor("목적을 위한", "s2") + else: + data["events"] = [ + event( + kind="attributed_report", + reporting_actor=anchor("발표처"), + predicate=anchor("알렸다"), + ) + ] + # The record works for the first binding, then fails for a later quote. + first_only = deepcopy(data) + first_only["bindings"].pop() + assert len(api().build_shared_measurement_frame(src, first_only).frame.scopes) == 1 + rejects(src, data) + + +def test_inclusion_three_edge_cycle_rejects_with_distinct_unordered_pairs(): + src = document("전체 이용자 10명, 20명, 30명 포함.") + data = { + "contexts": [context(measured_variable=anchor("이용자"))], + "events": [event()], + "bindings": [binding(), binding("n2"), binding("n3")], + "relations": [ + { + "kind": "included_in", + "from_id": a, + "to_id": b, + "statement_sentence_ids": ["s1"], + "cue": anchor("포함"), + } + for a, b in [("n1", "n2"), ("n2", "n3"), ("n3", "n1")] + ], + } + acyclic = deepcopy(data) + acyclic["relations"].pop() + assert ( + len(api().build_shared_measurement_frame(src, acyclic).frame.frame.resolved.relations) == 2 + ) + rejects(src, data) + + +@pytest.mark.parametrize("identity", ["_", "a" * 62 + "-_"]) +def test_id_boundaries_and_same_id_in_different_groups_are_allowed(identity): + src, data = simple() + data["contexts"][0].update(context_id=identity, reporting_event_id=identity) + data["events"][0]["event_id"] = identity + data["bindings"][0]["context_id"] = identity + result = api().build_shared_measurement_frame(src, data) + assert result.purposes[0].context_id == identity + + +@pytest.mark.parametrize( + "path", [("contexts",), ("contexts", 0, "target_scopes"), ("bindings", 0, "target_scopes")] +) +def test_list_in_actual_model_is_rejected_before_narrowing(path): + src, data = example() + result = api().build_shared_measurement_frame(src, data) + rejects(src, replace_model(result.input, path, list)) + + +def test_purpose_order_is_checked_when_contexts_are_selected_in_different_order(): + src, data = simple() + data["contexts"] = [context("c2"), context("c1", purpose=anchor("동부"))] + data["bindings"] = [binding(), binding("n2", "c2")] + result = api().build_shared_measurement_frame(src, data) + raw = result.model_dump(mode="json") + raw["purposes"].reverse() + with pytest.raises(api().SharedMeasurementContextError): + api().validate_shared_measurement_frame(src, raw) + + +def test_pure_build_and_validate_use_no_file_network_or_model_io(monkeypatch): + mod = api() + src, data = example() + + def forbidden(*args, **kwargs): + raise AssertionError("I/O forbidden") + + with monkeypatch.context() as patch: + for owner, name in [ + (builtins, "open"), + (io, "open"), + (os, "open"), + (Path, "open"), + (socket, "socket"), + (socket, "create_connection"), + (Agent, "run_sync"), + (Model, "request"), + ]: + patch.setattr(owner, name, forbidden) + result = mod.build_shared_measurement_frame(src, data) + assert mod.validate_shared_measurement_frame(src, result) == result From d6b3e93ae3714e30203e3990a4482cb53b8678a3 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Wed, 21 Oct 2026 03:55:05 +0000 Subject: [PATCH 2/5] test(agents): isolate shared collection cap validation --- packages/agents/tests/test_shared_measurement_context.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/agents/tests/test_shared_measurement_context.py b/packages/agents/tests/test_shared_measurement_context.py index f8519f51..e656027d 100644 --- a/packages/agents/tests/test_shared_measurement_context.py +++ b/packages/agents/tests/test_shared_measurement_context.py @@ -13,7 +13,7 @@ from uuid import uuid4 import pytest -from pydantic import Field, create_model +from pydantic import Field, ValidationError, create_model from pydantic_ai import Agent from pydantic_ai.models import Model from test_numeric_evidence import document @@ -225,6 +225,11 @@ def test_empty_full_input_is_valid_with_or_without_numeric_candidates(): def test_each_collection_over_cap_rejects(field): src, data = example() data[field] = [data[field][0]] * (17 if field == "relations" else 13) + with pytest.raises(ValidationError) as error: + api().SharedMeasurementInput.model_validate_json(json.dumps(data), strict=True) + assert [(item["loc"], item["type"]) for item in error.value.errors()] == [ + ((field,), "too_long") + ] rejects(src, data) From 8d53eb4c9033b755bcb416013b3db97c84844c53 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Wed, 21 Oct 2026 03:56:23 +0000 Subject: [PATCH 3/5] docs: record shared measurement context contract and verification --- README.md | 4 ++- docs/README.md | 4 ++- docs/changes/shared-measurement-context.md | 34 ++++++++++++++++++---- docs/planning/implementation-plan.md | 4 +-- docs/planning/progress.md | 10 +++++++ docs/planning/tasks.md | 2 +- packages/agents/README.md | 6 ++++ tests/evaluation/README.md | 4 +++ 8 files changed, 58 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c22e8fe3..0f7c7244 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # IssueDigest -현재 연구: [동일 계약 추론 비교](docs/research/measurement-reasoning-comparison.md). 네 시도 중 반환2개 모두 의미 미충족·다른2개 미판정으로 설정/앱을 바꾸지 않았다. [공유 측정 맥락](docs/changes/shared-measurement-context.md)을 다음 구현으로 설계했다. 실행 코드는 불변이다. +현재 구현: [공유 측정 맥락](docs/changes/shared-measurement-context.md). 공통 항목/범위·보고 사건을 명시 연결하고 목적을 조건과 분리하는 순수 adapter를 추가했다. 독립 검토와 전체6,916개 검사를 통과했다. 새 모델 호출0이며 provider 용량·미관측 의미 품질·앱 연결은 후속이다. 기존 결과와 요청 설정은 보존한다. + +현재 연구: [동일 계약 추론 비교](docs/research/measurement-reasoning-comparison.md). 네 시도 중 반환2개 모두 의미 미충족·다른2개 미판정으로 설정/앱을 바꾸지 않았다. 공유 맥락의 설계 후 순수 구현은 위 현재 기록을 따르며, 이 연구 당시 실행 코드는 보존했다. 현재 구현: [측정 범위 생성·문장 상태 근거](docs/changes/measurement-frame-generation.md). 새 범위 계약과 단일 요청을 연결하고 전체6,667개 검사를 통과했다. 실제4회는 수치17개를 보존했지만 의미 기준0/4로 앱 적용을 보류한다. [결과·남은 오류](docs/research/measurement-frame-generation-acceptance.md)를 보존한다. diff --git a/docs/README.md b/docs/README.md index 074002e5..ba050640 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,8 @@ # 문서 안내·협업 방법 -현재 연구: [동일 계약 추론 비교](research/measurement-reasoning-comparison.md). 네 시도 중 반환2개 모두 의미 미충족·다른2개 미판정으로 설정/앱을 바꾸지 않았다. [공유 측정 맥락](changes/shared-measurement-context.md)을 다음 구현으로 설계했다. 실행 코드는 불변이다. +현재 구현: [공유 측정 맥락](changes/shared-measurement-context.md). 공통 항목/범위·보고 사건을 명시 연결하고 목적을 조건과 분리하는 순수 adapter를 추가했다. 독립 검토와 전체6,916개 검사를 통과했다. 새 모델 호출0이며 provider 용량·미관측 의미 품질·앱 연결은 후속이다. 기존 결과와 요청 설정은 보존한다. + +현재 연구: [동일 계약 추론 비교](research/measurement-reasoning-comparison.md). 네 시도 중 반환2개 모두 의미 미충족·다른2개 미판정으로 설정/앱을 바꾸지 않았다. 공유 맥락의 설계 후 순수 구현은 위 현재 기록을 따르며, 이 연구 당시 실행 코드는 보존했다. 현재 구현: [측정 범위 생성·문장 상태 근거](changes/measurement-frame-generation.md). 새 범위 계약과 단일 요청을 연결하고 전체6,667개 검사를 통과했다. 실제4회는 수치17개를 보존했지만 의미 기준0/4로 앱 적용을 보류한다. [결과·남은 오류](research/measurement-frame-generation-acceptance.md)를 보존한다. diff --git a/docs/changes/shared-measurement-context.md b/docs/changes/shared-measurement-context.md index c0f1f787..8f4b5921 100644 --- a/docs/changes/shared-measurement-context.md +++ b/docs/changes/shared-measurement-context.md @@ -1,12 +1,12 @@ -# 공유 측정 맥락 · 다음 구현 설계 +# 공유 측정 맥락 · 순수 계약 -2026-10-21. 상태: 설계, 미구현. [동일 계약 추론 비교](../research/measurement-reasoning-comparison.md) 이후의 구현 단위다. 브리핑 제품 방향/기존 앱은 바꾸지 않으며 기존 실패를 변환해 성공으로 세지 않는다. +2026-10-21. 상태: 순수 계약 구현·합성 구조 인수 완료. 생성 SDK·실제 의미 인수·앱 적용은 미완료. [동일 계약 추론 비교](../research/measurement-reasoning-comparison.md) 이후의 구현 단위다. 브리핑 제품 방향/기존 앱은 바꾸지 않으며 기존 실패를 변환해 성공으로 세지 않는다. ## 해결할 문제 현재는 수치마다 대상·범위·발표자·기간·상태·보고 시제를 반복해서 채운다. 최신 제한 비교에서도 국가는 측정 대상으로 들어갔고, 같은 자료의 범위는 비었으며, 미래 공급 행위를 과거 발표의 시제로 사용했다. 추론 토큰 사용만으로 전체 의미 기준을 충족하지 못했다. 모델 성능 일반 결론이나 새 구조의 성공을 미리 주장하지 않는다. -## 제안 계약 +## 구현 계약 | 구성 | 역할 | 제안 상한 | | --- | --- | --- | @@ -33,9 +33,33 @@ ## 다음 작업과 인수 -- [ ] 순수 공유 맥락·보고 사건·수치 연결 계약, strict canonical 입력·위변조 거부·명시 확장·기존 프레임 재생성 구현. -- [ ] 합성 자료에서 같은 항목/서로 다른 국가·지역, 범위 상속/추가, 명시 발표와 운영자, 발표 시제와 미래 행위, 목적과 조건을 독립 검증. 공유 ID/unused record/중복/없는 참조/다른 인용·합친 범위 상한도 검사. +- [x] 순수 공유 맥락·보고 사건·수치 연결 계약, strict canonical 입력·위변조 거부·명시 확장·기존 프레임 재생성 구현. +- [x] 합성 자료에서 같은 항목/서로 다른 국가·지역, 범위 상속/추가, 명시 발표와 운영자, 발표 시제와 미래 행위, 목적과 조건을 독립 검증. 공유 ID/unused record/중복/없는 참조/다른 인용·합친 범위 상한도 검사. - [ ] provider bytes/출력·실제 budget 측정 후 생성 연결. 기존 raw/판정은 유지하고 고정 사례와 새로운 미관측 실제 자료를 별도 사전 기준·호출 상한으로 평가. - [ ] 전체 앱 예산·현재 권한·내구 실행/복구·실제 브리핑 품질을 인수한 뒤 앱 반영 판단. 설계/합성 검사/새 모델 반환/의미 인수/앱 적용을 구분. 설계 비용: 공유 맥락을 잘못 선택하면 여러 수치에 같은 오류가 전파될 수 있다. 따라서 명시 연결과 원본/확장 결과를 모두 검증·기록해야 한다. 새 계약 유지와 모델 품질/용량 인수가 추가되며, 이번 비교만으로 구조 변경의 효과를 입증하지 않았다. + +## 이번 순수 구현의 확정 계약 + +`shared_measurement_context.py`의 별도 v1 계약만 추가한다. 앱·생성 SDK·기존 요청 bytes/기준/결과·예산·수집기·의존성은 변경하지 않는다. 새 모델 호출은 없다. + +- `SharedMeasurementInput`은 contexts/events/bindings/relations를 모두 필수로 받는다. 각 상한은12/12/12/16이며 빈 전체 입력은 허용한다. ID는1~64자 ASCII 영숫자·밑줄·하이픈이며 그룹 내 고유하다. context/event는 반드시 사용되어야 하고 없는 참조는 거부한다. binding의 candidate_id는 기존 후보를 사용하며 중복 선택은 거부한다. +- `SharedMeasurementContext`는 context_id, reporting_event_id, measured_variable, target_scopes, period, value_status, logical_condition, purpose, forecast_basis, comparison_baseline을 모두 필수로 받는다. anchor 역할은 nullable이고 scopes는 최대4개다. 값 상태는 기존 observed/planned/forecast/unknown이다. +- `SharedReportingEvent`는 event_id, kind, reporting_actor, predicate, reporting_tense를 모두 필수로 받는다. attributed_report는 actor/predicate 필수, narration은 actor=null/predicate 필수, unknown은 actor/predicate=null 및 tense=unknown이다. 알려진 시제는 predicate가 필수다. 어떤 술어가 실제 보고인지는 의미 인수에 남긴다. +- `SharedNumericBinding`은 candidate_id/context_id/target_scopes/status_evidence_sentence_id를 필수로 받는다. status sentence는 nullable이며 알려진 값 상태면 필수다. 제공된 ID는 해당 후보의 수치를 포함한 현재 전체 문장이어야 하고 exact nonblank 길이1~1000이다. 서버가 그 전체 문장을 status_text로 만든다. 개별 역할 override는 없다. +- 명시 context와 event만 확장한다. 공통 scopes 뒤 개별 scopes를 붙여 최대4개다. 같은 resolved 위치 중복과 다른 인용의 모든 anchor(목적 포함)를 거부한다. 같은 유형의 다른 위치 범위는 허용한다. 원문상 목적/조건이 같은 위치라는 이유로 의미 판정을 대신하거나 자동 교정하지 않는다. +- `SharedMeasurementFrame`은 고정 profile shared-measurement-context-v1/status unreviewed, 원래 input, 파생 MeasurementScopeFrame, 입력 contexts 순서의 context별 resolved purpose(nullable)를 보존한다. purpose는 logical_condition에 복사하지 않는다. current source에서 전체 wrapper를 재구성해 비교하고 strict canonical 실제 타입·숨은 extra/subclass·위변조는 기존 패턴으로 거부한다. 오류는 source/검증 상세와 chain을 노출하지 않는다. +- 관계는 기존 ContextualFrameRelationInput 그대로 받고 기존 전체 statement/cue/참조/중복/순환 검사를 재사용한다. 모호한 관계를 버리거나 첫 위치를 고르지 않는다. 원문 일치·구조 검증은 의미/진실/중립/권한 승인이 아니다. + +### Task 1 — 순수 계약과 회귀 검사 + +- [x] 새 모듈과 합성 테스트만 추가하여 위 계약을 구현한다. 기존 모듈/fixture/SDK는 수정하지 않는다. 같은 항목·서로 다른 지역, 공통+개별 범위, 보고자/행위자·보고 시제/미래 행위, 목적/조건을 구분하는 합성 사례와 직접 관계 재사용을 검증한다. 각 상한·빈 입력·중복/없는/unused 참조·cross quote·exact 문장 상태·canonical 우회/위변조·오류 비노출을 독립 검사한다. 적절한 RED→GREEN과 focused/agents lint·typecheck를 기록한다. + +## 구현·검증 기록 + +`build_shared_measurement_frame` / `validate_shared_measurement_frame`은 원래 입력·분리 목적·파생 프레임을 현재 원문에서 재구성한다. 별도 새 모듈/합성 테스트만 추가했고 기존413개 실행/설정 파일·wire fixture·원래 응답/판정은 변경하지 않았다. 소스 `86e42721058de43bbb3b47fd2222e13ad80e2438`의 전체6,916개(Python6,096+JavaScript820)와 lint/format/typecheck/build/OpenAPI 검사를 통과했다. 구현자 TDD 기록·별도 태스크/최종 검토를 보존한다. + +모델 호출0·앱/서버/main/운영 변경0이다. 합성 사례는 정해진 연결의 원문 재현을 검사하며 AI가 그 맥락을 올바르게 선택하거나 실제 브리핑이 개선된다는 증거는 아니다. 기존 생성4회 의미0/4 및 추론 비교 반환2의 의미0/2·다른2미판정을 재판정하지 않는다. 다음은 새 provider schema의 실제 용량과 미관측 자료 의미 품질을 별도 상한으로 인수하는 것이다. + +최종 검토의 경미한 상한 테스트 지적은 테스트 전용 `d6b3e93`에서 보강했다. 네 필드 각각 strict schema의 정확 too_long 위치/종류를 검사하고, 메모리에서 상한만 제거한 대조4/4가 실패함을 확인했다. 생산 코드는 `86e4272`와 동일하며 최종 테스트249개·Ruff/형식 검사를 별도로 통과했다. 전체6,916개 결과는 상한 보강 전 테스트를 포함한 `86e4272`의 실행이며 보강 후 전체를 다시 실행한 것으로 표시하지 않는다. diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md index 3a17dbe4..63efaf64 100644 --- a/docs/planning/implementation-plan.md +++ b/docs/planning/implementation-plan.md @@ -56,7 +56,7 @@ SDK 단독 상한은 요청1회·입력+출력12,000/출력500/15초다. 실제 | --- | --- | --- | --- | | M1 · 질문에서 브리핑까지 | 로그인·권한·저장 검색·AI 생성·정확 인용·저장/재열람·실패/취소/복구 연결 | 기반 완료 · 개인 개발 적용 | 개발 웹/API/워커ab6654e/v9·전체4,655개/독립 검토·실제 HTTPS/보존 복원. 의미 품질 부분 충족이며 운영 출시와 구분 | | M2 · 자료 확보·갱신 | 승인 출처를 반복 확보하고 변경/중복·실패·수집 범위/최신성을 추적, 과거 근거 보존 | 진행 중 | 시간당 고정 기사 확보·명시 승인 편입과 날짜/발행처 후보 비교를 구현. 새 공식 텍스트1건 추가·전체3,603개·독립 검토·검색/재실행·사후23테이블 일치 복원 확인. 다매체 확대·최근 주목도·자동 승인/게시 완료는 아님 | -| M3 · 근거 중심 자율 조사 | 질문별 승인 도구 탐색·비교·자료 부족 판단과 검증 모듈 전달, 비용/취소/복구 | 진행 중 · 프레임 생성/의미 인수 | 새 프레임SDK 실제2회 반환1/거부1·의미0/2·전체5937개. 원래 실패/비용 보존. 다음 모델 제한 비교·반복anchor/대상/관계·앱 인수 후속 | +| M3 · 근거 중심 자율 조사 | 질문별 승인 도구 탐색·비교·자료 부족 판단과 검증 모듈 전달, 비용/취소/복구 | 진행 중 · 공유 맥락 순수 계약 | 공통 대상/범위·보고 사건·분리 목적의 명시 연결과 전체6,916개 검사 완료. 실제 AI 선택·미관측 의미 품질·전체 앱 인수는 후속이며 기존 실패 보존 | | M4 · 관심 이슈·변화·공유 | 개인 소유·관심 해제/삭제, 새 사실·공식 결정·정정과 알림함, 동의한 불변 공유본 | 예정 | 소유/접근 모델·변경 근거부터 구현. 현재 검증용 공유 이력을 개인 보관함으로 간주하지 않음 | | M5 · 이슈 발견·개인화 | 실제 매체 관측/중복 제거/선정 이유와 동의한 관심 신호의 혼합 | 예정 | 출처/기간/갱신 데이터와 신호 정책·다양성 평가. 임의 인기·여론 점수를 만들지 않음 | | M6 · 공개 운영 | 실제 질문 품질·지연/비용·권한·백업 복원·공개 이용 정책 충족 후 출시 판단 | 예정 · 운영 게시 유예 | 개발 HTTPS·앱 백업 복원 기반은 있음. 전체 출시 조건과 D013 별도 승인 필요 | @@ -150,4 +150,4 @@ D022의 제한 한국어 검색·운영자 read-only 평가와 v4는 실제 모 ## 의미 품질 후속 · 공유 측정 맥락 설계 -[동일 계약 추론 비교](../research/measurement-reasoning-comparison.md)의 반환2건도 의미 기준을 충족하지 못했고2건은 미판정이다. 추론만 켜면 해결된다고 가정하지 않는다. [공유 측정 맥락 설계](../changes/shared-measurement-context.md)는 공통 대상/범위/기간과 보고 사건을 명시 연결하고 기존 프레임으로 확장한다. 목적과 논리 조건을 분리하며 자동 추정 상속/사후 수리는 하지 않는다. 설계만 기록했으며 새 계약 구현·미관측 의미 인수·용량/전체 앱 검증이 남는다. +[동일 계약 추론 비교](../research/measurement-reasoning-comparison.md)의 반환2건도 의미 기준을 충족하지 못했고2건은 미판정이다. 추론만 켜면 해결된다고 가정하지 않는다. [공유 측정 맥락 설계](../changes/shared-measurement-context.md)는 공통 대상/범위/기간과 보고 사건을 명시 연결하고 기존 프레임으로 확장한다. 목적과 논리 조건을 분리하며 자동 추정 상속/사후 수리는 하지 않는다. 순수 계약·합성 구조 인수와 독립 검토는 완료했다. 새 provider 용량·미관측 의미 인수·전체 앱 검증이 남는다. 모델 호출과 앱 변경 없이 기존 결과/기준을 보존한다. diff --git a/docs/planning/progress.md b/docs/planning/progress.md index cb763959..56a48ae8 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -1,3 +1,13 @@ +# 공유 측정 맥락 · 순수 구현·검증 · 2026-10-21 + +[명세](../changes/shared-measurement-context.md). 공통 항목/범위·보고 사건을 명시 ID로만 연결하고 목적을 조건과 분리했다. 원래 입력·resolved 목적·파생 기존 프레임을 현재 source에서 전부 재현하며 unused/없는 참조·다른 인용·중복/상한·위변조를 거부한다. 기존 모듈과 요청/결과를 보존한 새 순수 모듈/합성 테스트다. + +코드 `86e42721058de43bbb3b47fd2222e13ad80e2438`의 전체6,916개(Python6,096+JavaScript820)·Ruff/format/mypy·ESLint/TypeScript/build/OpenAPI 검사를 통과했다. 구현 TDD·별도 태스크/최종 독립 검토와 소유 QA 증거를 private 기록에 보존했다. 기존413 실행/설정 파일·이전 연구/응답/판정 불변을 별도로 확인한다. + +최종 상한 테스트 보강은 `d6b3e93`의 테스트 전용 변경이다. 네 상한 제거 대조4/4 실패·정상249개/정적 검사를 별도로 확인했고 생산 코드는 전체 검사 당시와 동일하다. 전체 검사를 보강 후 다시 실행한 것으로 기록하지 않는다. + +새 모델 호출0·앱/서버/main/운영 변경0이다. 합성 구조 통과가 실제 AI 의미 개선은 아니다. 이전 생성 의미0/4·추론 비교 반환2의 의미0/2/다른2미판정과 비용 미확인은 유지한다. 다음은 새 provider schema/출력 용량·미관측 실제 자료 의미 품질, 이후 전체 앱 예산/권한/내구 실행 인수다. Sites와 Draft PR 영수증은 후속 기록으로 확정한다. + # 동일 계약 추론 비교 · Sites v87 영수증 · 2026-10-21 KST [Draft PR65](https://github.com/DigestWorks/IssueDigest/pull/65)은 PR64 기반이다. Sites v87 native succeeded(2026-10-21T12:26:10.589073+09:00), source2df52779c219d374092638944a54146267dc205c, 저장소 사본73a5da35f4978f147d2dcb26e7e3dfe2d49412cc. 사본 이후 최종 영수증은 원본 저장소를 따른다. 이전v86 보존·HTML72/로컬 링크·asset·anchor1420/공개 텍스트211·private source/ID26종 포함219파일 제외 검사를 확인했다. 텍스트/기록 변경으로 browser QA는 하지 않았고 사용자 탭을 열거나 이동하지 않았다. diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index 0841d88f..eddd7506 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -2,7 +2,7 @@ - [x] [동일 계약 추론 설정 비교](../research/measurement-reasoning-comparison.md): 최대4시도·응답확인3·반환2/거부2, 의미판정가능2건 모두 미충족·2건미판정. 상한 소진/원래 기준 유지. - [x] [공유 측정 맥락](../changes/shared-measurement-context.md) 다음 구현 설계와 비용/제한 기록(코드 구현 아님). -- [ ] 공유 측정 맥락·보고 사건·수치 연결의 순수 계약과 기존 프레임 확장 구현. +- [x] [공유 측정 맥락](../changes/shared-measurement-context.md)·보고 사건·수치 연결의 순수 계약/전체 재구성·합성 검사·독립 검토 완료. 새 모델/앱 적용과 구분. - [ ] 새 provider/용량 검증·미관측 실제 자료 인수, 전체 앱 예산/내구 실행 확인 뒤 반영 판단. ## 측정 범위 생성 · 현재 diff --git a/packages/agents/README.md b/packages/agents/README.md index f3aa90ca..d08fee74 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -527,3 +527,9 @@ proposal=None이면 outcome=rejected, checks는 빈 tuple, received_facts/propos 보고서는 원문·anchor·질문을 복사하지 않고 candidate_id/field/result만 기록하지만 ID도 민감할 수 있으므로 호출자가 보관 경계를 지킨다. 신규 합성 회귀는 `uv run --offline pytest packages/agents/tests/test_numeric_selection_evaluation.py -q`로 실행한다. + +## 공유 측정 맥락 · 순수 v1 + +`issuedigest_agents.shared_measurement_context`의 `SharedMeasurementInput`과 `build_shared_measurement_frame(source, input)` / `validate_shared_measurement_frame(source, frame)`을 사용한다. contexts/events/bindings/relations는 필수이며 최대12/12/12/16개다. 명시 context/event ID로만 상속하고 combined scopes≤4·모든 anchor 같은 인용·수치 포함 전체 문장 상태 근거를 검사한다. unused/없는 참조/중복·strict canonical 우회·전체 wrapper 위변조는 상세 없는 전체 오류다. + +원래 입력과 기존 MeasurementScopeFrame 및 contexts 순서의 resolved purpose를 보존한다. 목적은 조건으로 변환하지 않는다. status는 unreviewed이며 위치·연결은 의미/진실/권한 인증이 아니다. [명세](../../docs/changes/shared-measurement-context.md)를 따른다. 기존 API/SDK/wire/앱은 보존하고 provider 연결·용량/실제 의미 품질은 후속이다. 새 모델 호출0·전체6,916개 검사와 실제 앱 인수는 별개다. diff --git a/tests/evaluation/README.md b/tests/evaluation/README.md index afac5c6f..340c15f6 100644 --- a/tests/evaluation/README.md +++ b/tests/evaluation/README.md @@ -441,3 +441,7 @@ source09d6624의 새 평가기로 기존 SDK 반환1/거부1을 재생했다. ### 수치 역할 평가기 · 최종 전체 검사 검토된64b2f19(실행 코드2bd1f37)에서 Python4,854+JS820=5,674개·Ruff/format330/mypy133·ESLint/TS/build/OpenAPI drift가 통과했다. 실패/skip/warning0이다. 작업 검토 Important1건을 고친 뒤 수정 범위 재검토와 전체 독립 Spec/품질 PASS(C0I0M0)를 확인했다. source09의 중단된 사전 gate/취소 CI39393254153은 최종 성공에서 제외한다. [Draft PR55](https://github.com/DigestWorks/IssueDigest/pull/55)는 PR54 기반·미병합이다. [최종 source CI39394088829](https://github.com/DigestWorks/IssueDigest/actions/runs/39394088829)와 Sites/소유QA·SDD 정리는 최종 게시 receipt를 따른다. 이후 변경은 Markdown뿐이다. 신규 모델0·원래 의미0/2/모든 실패 usage·이번 세 판단/비용과 과거 판단을 유지한다. + +## 공유 측정 맥락 · 합성 구조 검사 + +[명세](../../docs/changes/shared-measurement-context.md)의 공통 항목/범위·보고 사건·수치 연결과 목적/조건 분리를 합성 자료로 검사했다. 새 원본/파생 프레임 전체 재구성·strict canonical·다른 인용/중복/unused·상한·관계 재사용을 검증한다. 전체6,916개 검사(Python6,096/JavaScript820)를 통과했으나 모델 호출0이고 모델 선택·의미/중립성·브리핑 인수는 아니다. 기존 생성4회0/4와 추론 비교 의미0/2·다른2미판정, 미확인 비용/원래 gold는 유지한다. From 4e7de580dac3a0f4aaad016e8aea10cd6aa26469 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Wed, 21 Oct 2026 03:56:55 +0000 Subject: [PATCH 4/5] docs: link shared context draft review --- docs/planning/progress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/planning/progress.md b/docs/planning/progress.md index 56a48ae8..bfedb562 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -6,7 +6,7 @@ 최종 상한 테스트 보강은 `d6b3e93`의 테스트 전용 변경이다. 네 상한 제거 대조4/4 실패·정상249개/정적 검사를 별도로 확인했고 생산 코드는 전체 검사 당시와 동일하다. 전체 검사를 보강 후 다시 실행한 것으로 기록하지 않는다. -새 모델 호출0·앱/서버/main/운영 변경0이다. 합성 구조 통과가 실제 AI 의미 개선은 아니다. 이전 생성 의미0/4·추론 비교 반환2의 의미0/2/다른2미판정과 비용 미확인은 유지한다. 다음은 새 provider schema/출력 용량·미관측 실제 자료 의미 품질, 이후 전체 앱 예산/권한/내구 실행 인수다. Sites와 Draft PR 영수증은 후속 기록으로 확정한다. +새 모델 호출0·앱/서버/main/운영 변경0이다. 합성 구조 통과가 실제 AI 의미 개선은 아니다. 이전 생성 의미0/4·추론 비교 반환2의 의미0/2/다른2미판정과 비용 미확인은 유지한다. 다음은 새 provider schema/출력 용량·미관측 실제 자료 의미 품질, 이후 전체 앱 예산/권한/내구 실행 인수다. [Draft PR66](https://github.com/DigestWorks/IssueDigest/pull/66)은 PR65 기반이다. Sites 요약/이전v87 보존·출판 영수증은 후속 기록으로 확정한다. # 동일 계약 추론 비교 · Sites v87 영수증 · 2026-10-21 KST From 5f628c4b2310fea7a617068bf264c2bd6aa4e499 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Wed, 21 Oct 2026 03:58:57 +0000 Subject: [PATCH 5/5] docs: record Sites88 publication and verification receipt --- docs/planning/progress.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/planning/progress.md b/docs/planning/progress.md index bfedb562..a4afbb91 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -1,3 +1,9 @@ +# 공유 측정 맥락 · Sites v88 영수증 · 2026-10-21 KST + +[Draft PR66](https://github.com/DigestWorks/IssueDigest/pull/66)은 PR65 기반이다. Sites v88 native succeeded(2026-10-21T12:58:25.195086+09:00), source `d7f74621a9ace19cab40fb298dcc3737c8827685`, 저장소 사본 `4e7de580dac3a0f4aaad016e8aea10cd6aa26469`. 사본 이후 최종 출판 영수증은 원본 저장소를 따른다. 이전v87 보존·HTML73/로컬 링크·asset·anchor1442/공개 텍스트212·private26종 포함221파일 제외 검사를 확인했다. 텍스트/기록 변경으로 browser QA는 하지 않았으며 사용자 탭을 열거나 이동하지 않았다. + +생산 코드86e4272의 전체6,916개 검사와 테스트 전용d6b3e93의 별도249개/네 상한 제거 대조를 구분한다. 태스크·최종 독립 검토/수정분 재검토 완료, 기존413 실행/설정 파일·이전18evidence/최근65연구JSON 불변을 확인했다. 소유 QA1개는 ID/이름/image/label/loopback binding 검증 후 삭제했고 이 작업의 SDD 자료만 private 보존/정리했다. 다른 자원과 기존 worktree는 유지했다. 새 모델 호출·앱/서버/main/운영 변경0이며 실제 AI 선택/의미 품질·생성 용량·앱 통합은 후속이다. + # 공유 측정 맥락 · 순수 구현·검증 · 2026-10-21 [명세](../changes/shared-measurement-context.md). 공통 항목/범위·보고 사건을 명시 ID로만 연결하고 목적을 조건과 분리했다. 원래 입력·resolved 목적·파생 기존 프레임을 현재 source에서 전부 재현하며 unused/없는 참조·다른 인용·중복/상한·위변조를 거부한다. 기존 모듈과 요청/결과를 보존한 새 순수 모듈/합성 테스트다.