From 77ac09760d8cb228f5fca3610368cdfd92136e78 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:39:23 +0900 Subject: [PATCH 01/12] fix(core): align Moirai judgment policy and bounded output contracts Preserve legacy policy replay and mechanism revisions while holding incomplete judgments under policy revision two. Prepare bounded prose before immutable hashing and retain clipping provenance. Restore the Codex and OpenCodex backend decision in the canonical roadmap. --- AGENTS.md | 4 +- docs/ARCHITECTURE.md | 2 +- docs/MOIRAI_ENGINE.md | 48 +++---- docs/PLANNING.md | 6 +- .../030_moirai_refactor_plan.md | 18 +-- .../016_neural_preference_contract.md | 28 ++-- .../platform/017_moirai_module_composition.md | 32 ++--- packages/lina-core/src/agents/index.ts | 1 + .../lina-core/src/agents/judgment-dialogue.ts | 58 ++++++++- .../lina-core/src/agents/judgment-output.ts | 24 ++++ .../lina-core/src/agents/judgment-policy.ts | 38 +++++- .../src/agents/judgment-validation.ts | 73 ++++++++++- packages/lina-core/src/agents/judgment.ts | 11 +- .../lina-core/test/judgment-alignment.test.ts | 122 ++++++++++++++++++ .../test/judgment-dialogue-contract.test.ts | 43 ++++++ 15 files changed, 434 insertions(+), 74 deletions(-) create mode 100644 packages/lina-core/src/agents/judgment-output.ts create mode 100644 packages/lina-core/test/judgment-alignment.test.ts diff --git a/AGENTS.md b/AGENTS.md index fe8eb9d..14eade3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Lina — agent guide - Read [POLICY.md](POLICY.md) for development, CI, merge and release authority; [CONTRIBUTING.md](CONTRIBUTING.md) is the contribution entry point. Keep repository rules here, not in global client settings. -- Engine work follows the [Moirai engine design](docs/MOIRAI_ENGINE.md): three judgment modules with distinct objectives (Clotho: future outcomes, Lachesis: desire and learned preference, Atropos: adopted intentions), Moirai arbitration under a declared policy, Host ownership of records and handoff, and existing owners keeping their sources. The design is confirmed but not implemented; it is not evidence of orchestration, circuit learning or qualification. +- Engine work follows the [Moirai engine design](docs/MOIRAI_ENGINE.md): three judgment modules with distinct objectives (Clotho: future outcomes, Lachesis: desire and learned preference, Atropos: adopted intentions), Moirai arbitration under a declared policy, Host ownership of records and handoff, and existing owners keeping their sources. F1 common contracts are implemented; product integration remains planned, and this is not evidence of orchestration, circuit learning or qualification. - Bun workspace; `bun test` / `bun run typecheck` / `bun run lint` from the root. - Strict TS (see tsconfig.json), Biome, no default exports. -- Current runtime execution is Codex: `lina-codex` owns its RPC adapter and OpenCodex owns provider routing. The confirmed engine design (D13, D21) selects Senpi as the cognition backend, `omo app-server` as the development-task execution backend behind the existing `TaskManager` contract, and Senpi native provider accounts instead of OpenCodex; Codex CLI and OpenCodex are retired only when the F1–F4 roadmap lands that switch, not through ad-hoc edits. `lina-runtime/src/host.ts` contains Lina-owned contracts; do not introduce an engine SDK dependency into channels or memory. +- Codex owns current and planned cognition/task execution: `lina-codex` owns the RPC adapter and TaskManager contract; OpenCodex owns provider routing. Moirai D22 supersedes the Senpi/OMO migration in D13/D21. Preserve Codex/OpenCodex and existing data. `lina-runtime/src/host.ts` contains Lina-owned contracts; do not introduce an engine SDK dependency into channels or memory. - Discord layering: `discord.js` only in `discord/gateway-source.ts`; `lina-channels` never imports `lina-runtime`. QA scripts under `scripts/qa/` are exempt from the discord.js restriction. - Every behavior change starts with a failing test; tests await signals, never sleep. - Runtime artifacts (`.lina-sessions`, `data/memory`, `data/notepad.md`) are gitignored. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f4674ba..4e2f71d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ Lina owns persistent agent conversations, persona and memory, task coordination and the user interface. Codex is the execution engine; OpenCodex owns provider routing. The current execution and configuration contract is [CODEX_RUNTIME](CODEX_RUNTIME.md). [Product plans](PLANNING.md) distinguish current behavior from proposed UI, desktop, history and world features. -The agent core is the [Moirai engine](MOIRAI_ENGINE.md): three judgment modules with distinct objectives (Clotho: future outcomes, Lachesis: desire and learned preference via a MaleCNS mushroom-body subcircuit, Atropos: adopted intentions and continuity), Moirai arbitration under a declared per-catalog policy, and a Host that validates, records and hands off to existing owners. Its selected cognition backend is Senpi; development tasks move to `omo app-server` behind the existing TaskManager contract, and provider accounts, model listing and usage move to Senpi native (`account/*`, `model/list`), retiring Codex CLI and OpenCodex once the F1–F4 roadmap lands that switch (D21). [030](plans/context-engines/030_moirai_refactor_plan.md) keeps engine activity behind the ordinary conversation and replaces user-composed backend/model settings with maintainer-qualified presets. The [R0 QA probe](plans/context-engines/031_moirai_r0_evidence.md) exercises four persistent native threads separately from the product. Engine implementation, preset enforcement and the new conversation boundary remain planned; the Codex execution and model APIs described below still apply to the current runtime. +The agent core is the [Moirai engine](MOIRAI_ENGINE.md): three judgment modules with distinct objectives (Clotho: future outcomes, Lachesis: desire and learned preference via a MaleCNS mushroom-body subcircuit, Atropos: adopted intentions and continuity), Moirai arbitration under a declared per-catalog policy, and a Host that validates, records and hands off to existing owners. Its current and selected cognition/task backend is Codex, with the existing lina-codex RPC adapter and TaskManager. OpenCodex retains provider accounts, routing, model listing and usage ownership. D22 supersedes the Senpi/OMO migration in D13/D21. F1 judgment contracts are implemented; D23 adds bounded prose preparation and policy revision 2 while retaining historical revision-1 replay. [030](plans/context-engines/030_moirai_refactor_plan.md) keeps engine activity behind the ordinary conversation and replaces user-composed backend/model settings with maintainer-qualified presets. The [R0 QA probe](plans/context-engines/031_moirai_r0_evidence.md) exercises four persistent native threads separately from the product. Product mechanism integration, preset enforcement and the new conversation boundary remain planned; the Codex execution and model APIs described below still apply to the current runtime. ```text browser -> same-origin gateway -> Lina agent/task controller diff --git a/docs/MOIRAI_ENGINE.md b/docs/MOIRAI_ENGINE.md index effeccc..19b125f 100644 --- a/docs/MOIRAI_ENGINE.md +++ b/docs/MOIRAI_ENGINE.md @@ -1,6 +1,6 @@ # LINA 모이라이 엔진 -상태: 2026-09-12 확정 설계. 이 문서는 모이라이 엔진의 정의·세 판단 모듈·회차 실행 형태·기존 모듈 배치·확정된 결정의 정본이다. 타입·포트·저장·복구의 세부 규칙은 [016 계약](plans/platform/016_neural_preference_contract.md), 구현 순서는 [017 로드맵](plans/platform/017_moirai_module_composition.md), 과학적 근거와 출처는 [015 근거](plans/platform/015_neural_preference_engine_research.md), 채널·모델 프리셋 운영은 [030 운영](plans/context-engines/030_moirai_refactor_plan.md)이 소유한다. 이 설계는 아직 구현되지 않았다. 문서 확정은 판단 효용·회로 학습·운영 성능의 증거가 아니며, 그 검증은 [017의 F1–F4](plans/platform/017_moirai_module_composition.md#후속-설계와-구현의-의존-순서)에서 실제 입력·계산 receipt·결과로 수행한다. +상태: 2026-09-13 확정 설계(D22·D23 반영). 이 문서는 모이라이 엔진의 정의·세 판단 모듈·회차 실행 형태·기존 모듈 배치·확정된 결정의 정본이다. 타입·포트·저장·복구의 세부 규칙은 [016 계약](plans/platform/016_neural_preference_contract.md), 구현 순서는 [017 로드맵](plans/platform/017_moirai_module_composition.md), 과학적 근거와 출처는 [015 근거](plans/platform/015_neural_preference_engine_research.md), 채널·모델 프리셋 운영은 [030 운영](plans/context-engines/030_moirai_refactor_plan.md)이 소유한다. PR #14에 F1 공통 계약이 구현됐고, 세 메커니즘과 제품 연결은 F2–F4에서 구현한다. 문서 확정은 판단 효용·회로 학습·운영 성능의 증거가 아니며, 그 검증은 [017의 F1–F4](plans/platform/017_moirai_module_composition.md#후속-설계와-구현의-의존-순서)에서 실제 입력·계산 receipt·결과로 수행한다. ## 한눈에 보기 @@ -22,7 +22,7 @@ LINA는 이 엔진을 사용하는 제품이다. 엔진은 개인 대화, 약속 | --- | --- | | 세 판단·종합·선택 규칙, 목표 프로필, 회차 기록, 의도·약속의 채택 기록, 예측과 오차, 신경 선호 상태·학습, 선택 난수와 실행 요청 outbox | 사실 기억·대화 원문·정체성·사용자 지시·권한의 원본, 세계 상태, 작업 실행 상태, 게시·이미지 결과, 채널 전송 | -LLM 실행 백엔드는 **Senpi**다. Senpi는 세 판단과 종합의 의미 해석·계획 생성을 맡는 어댑터이며, 각 모듈의 코드 검사·기억 조회·수치 계산을 대체하지 않는다. 개발 작업의 실행 엔진은 **OMO native**(`omo app-server`, Senpi의 app-server 모드)이고, 프로바이더 계정·모델 목록·인증·사용량은 **Senpi native**(`account/*`, `model/list`, `config/read`)가 소유한다. 현재 런타임의 Codex CLI와 OpenCodex Hub는 F2–F4 전환이 끝나면 폐기한다(D21). 라케시스의 학습된 선호는 **MaleCNS v1.0 부분회로**를 별도 Python 계산기로 실행한다. 이 회로는 엔진 완성의 필수 요소이며 뒤로 미루는 선택 사항이 아니다. +LLM 실행 백엔드는 **Codex**다(D22). 기존 `lina-codex` 어댑터가 세 판단과 종합의 의미 해석·계획 생성을 연결하며, 각 모듈의 코드 검사·기억 조회·수치 계산은 별도 책임이다. 개발 작업은 기존 **Codex app-server와 TaskManager**, 프로바이더 계정·모델 목록·인증·사용량은 **OpenCodex**가 소유한다. Senpi/OMO 전환과 Codex·OpenCodex 폐기 계획(D13·D21)은 철회했다. 라케시스의 학습된 선호는 **MaleCNS v1.0 부분회로**를 별도 Python 계산기로 실행한다. 이 회로는 엔진 완성의 필수 요소다. ## 세 판단 모듈 @@ -74,12 +74,12 @@ PR #10의 역할 프롬프트는 라케시스를 "근거와 믿음의 타당성" ### personal.v1 조정 정책 -행동 catalog마다 정책을 선언한다. World 없는 개인의 첫 catalog `personal.v1`의 정책은 다음 순서로 동작한다. +행동 catalog마다 정책을 선언한다. World 없는 개인의 첫 catalog `personal.v1`은 policy revision 2를 사용한다(D23). revision 1은 과거 기록 재생에만 유지한다. 현재 정책의 순서는 다음과 같다. 1. **Host 적격성 검사(객관적 중단 조건).** 현재 권한 위반, 명시적 금지, 필수 근거 누락, 전제조건 실패인 후보는 제외한다. 제외 후보의 `p0`는 0이다. 이 단계는 목표 판단이 아니라 사실·권한 검사다. 2. **목표별 평가 수집.** 각 모듈은 적격 후보마다 `stance ∈ { prefer, accept, oppose, unavailable }`, 이득·손실·불확실성, 근거를 남긴다. 숫자 점수는 단위와 비교 규칙이 선언된 경우에만 사용한다. `unavailable`은 정책이 허용한 사유가 있어야 하며 0점이나 중립으로 해석하지 않는다. -3. **약속 보호.** 아트로포스가 `oppose`에 `severity: commitment_breach`(수락된 약속의 위반)를 붙인 후보는, 그 후보 자체가 원래 수락 근거를 참조하는 의도 변경 행동(`intention.suspend | cancel`)이 아니면 `p0`를 0으로 둔다. 취향이나 관심의 우선순위 의견은 이 단계에 해당하지 않는다. -4. **실행 불가 제외.** 클로토가 `oppose`에 `severity: infeasible`(검증 가능한 전제 실패)를 붙인 후보는 `p0`를 0으로 둔다. +3. **약속 보호.** 아트로포스의 `oppose`·`severity: commitment_breach`가 실제 수락된 의도로 귀속되고 Host가 의도 owner의 기록을 검증했을 때만 후보의 `p0`를 0으로 둔다. 검증된 위반 대상 하나와 정확히 같은 의도의 `intention.suspend | cancel`은 순위상 제외 예외이며 실행 권한을 주지는 않는다. 취향이나 관심의 우선순위 의견은 이 단계에 해당하지 않는다. +4. **실행 불가 근거.** 클로토의 `infeasible` 라벨만으로 제외하지 않는다. 검증된 전제 실패는 1단계 Host 적격성에 반영한다. 별도 `infeasible` 제외 stage는 과거 receipt 해석에만 남는다. 5. **상황별 목표 순서.** 남은 후보를 회차의 `situation`에 따라 선언된 목표 순서로 사전식 정렬한다. 같은 목표 안에서는 `prefer > accept > oppose` 순이다. | situation | 목표 순서 | 이유 | @@ -88,11 +88,11 @@ PR #10의 역할 프롬프트는 라케시스를 "근거와 믿음의 타당성" | `autonomous` (사용자 요청 없는 자율 활동 슬롯) | 라케시스 → 클로토 → 아트로포스 | 자율 시간의 선택은 개인의 욕구가 주도하되 약속 보호(3단계)는 유지된다 | | `transition` (진행 중 작업·의도의 전환 판단) | 클로토 → 아트로포스 → 라케시스 | 전환의 이득과 비용이 우선, 기존 약속이 그다음 | - 회차의 `situation`은 Host가 snapshot에서 결정하며 LLM이 바꾸지 못한다. 순위가 같은 후보는 동률이다. -6. **기준 분포 `p0`.** 순위 `r = 1, 2, …`에 `p0(r) ∝ ratio^(r-1)`을 배정하고 동률은 같은 순위의 질량을 균등 분할한다. `personal.v1`의 `ratio = 0.5`는 policy revision 1의 선언값이다. 측정값이 아니며 변경 시 policy revision을 올린다. + 회차의 `situation`은 Host가 snapshot에서 결정하며 LLM이 바꾸지 못한다. 순위가 같은 후보는 동률이다. 남은 후보 중 하나라도 필수 모듈의 평가가 `unavailable`이면 순위를 만들지 않고 보류한다. 해당 모듈의 다른 의견을 회차 전체에서 제거하지 않는다. Host는 원래 요청의 남은 예산 안에서 보완하며, 예산이 끝나면 `deferred`로 종료한다. +6. **기준 분포 `p0`.** 순위 `r = 1, 2, …`에 `p0(r) ∝ ratio^(r-1)`을 배정하고 동률은 같은 순위의 질량을 균등 분할한다. `personal.v1`의 `ratio = 0.5`는 처음 policy revision 1에서 선언했고 revision 2에서도 유지하는 값이다. 측정값이 아니며 변경 시 policy revision을 올린다. 7. **신경 선호 반영.** `p(a) ∝ p0(a) × exp(λ × b(a))`. `personal.v1`의 `λ`는 `user_request`에서 0, `autonomous`·`transition`에서 1이다. 사용자 지시가 있는 회차에서는 학습된 취향이 선택 확률을 바꾸지 않는다. `b`가 `unavailable`인 후보는 신경 장애 무변조 규칙을 따른다. -8. **기록.** `ResolutionRecord`에 목표별 추천, 충돌한 요구, 3·4단계에서 제외된 후보와 이유, 5단계의 순서와 양보한 목표를 남긴다. `SelectionSpec`에 적격 후보·`p0`·`b`·`λ`·정책 revision을 고정한다. -9. **보류.** 적격 후보가 없으면 `deferred`로 끝내고 확인 질문 또는 다음 회차로 넘긴다. 필요한 평가가 `unavailable` 사유 없이 비어 있으면 회차를 보류한다. 의견 불일치 자체는 보류 사유가 아니다. +8. **기록.** `ResolutionRecord`에 목표별 추천, 충돌한 요구, Host 적격성·약속 보호에서 제외된 후보와 이유, 5단계의 순서와 양보한 목표를 남긴다. `SelectionSpec`에 적격 후보·`p0`·`b`·`λ`·정책 revision을 고정한다. +9. **보류.** 적격 후보가 없으면 `deferred`로 끝내고 확인 질문 또는 다음 회차로 넘긴다. 필요한 평가가 없거나 남은 후보의 평가가 `unavailable`이면 회차를 보류한다. 의견 불일치 자체는 보류 사유가 아니다. LIFE catalog는 F3에서 같은 형식으로 자기 `situation`·목표 순서·`ratio`·`λ`를 선언한다. 선언한 정책이 없는 catalog는 실행하지 않는다. @@ -144,9 +144,9 @@ flowchart TD | LIFE 욕구·목표·사건 선택 | 욕구 drift, 목표 우선순위, 사건/참여자 선택 | 욕구·목표 원본 재사용. 개인의 행동 선택은 `decisionMode: moirai`에서 엔진으로 이동 | | Ensemble 어댑터 | 사회적 의향·행동 그래프·상대 반응 | 사회 도메인 서비스. 의향 계산과 최종 행동 적용 포트 분리 | | NativePersonaGrowth·BehaviorStore | 유효 기억의 성향 해석과 투영 | 성향 owner. dimension별 `reflection \| neural` 생성자 중 하나 | -| TaskManager·작업 도구 | 개발 작업 시작·지시·중단·인계 | 실행 owner. `task.*` 행동의 effect owner. 백엔드는 Codex CLI에서 OMO native app-server로 전환(D21), Lina 측 작업 ID·receipt·권한 계약은 유지 | +| TaskManager·작업 도구 | 개발 작업 시작·지시·중단·인계 | 실행 owner. `task.*` 행동의 effect owner. Codex app-server를 유지(D22), Lina 측 작업 ID·receipt·권한 계약은 유지 | | 이미지·LIFE 게시 | 생성·편집·게시·답글 | 표현·실행 owner. 무엇을 표현할지는 엔진, 검사·렌더링·전송은 owner | -| AgentFleet·세션 조립 | 개인별 세션·저장소·도구 연결 | Host 조정기와 Senpi 역할 세션의 설치 지점 | +| AgentFleet·세션 조립 | 개인별 세션·저장소·도구 연결 | Host 조정기와 Codex 역할 세션의 설치 지점 | | 실행 통제·DurableRuntime | 요청·응답 기록, 권한·취소·복구 | 확정된 판단을 실제 효과로 넘기는 경계 | | 웹·Discord 채널 | 외부 입력·최종 응답 전달 | 세 의견을 세 메시지로 보내지 않음 | | 모델 서비스·체크포인트 | 모델 라우팅, 오프라인 캡처·복원 | 공통 인프라. 엔진 저장소를 기존 checkpoint manifest에 편입 | @@ -164,7 +164,7 @@ World 없는 개인이 엔진으로 확정할 수 있는 행동의 유한 목록 | `intention.suspend` / `intention.resume` | 보류·재개 | 원래 수락 근거 참조, 보류 사유 | 상태 전이와 사유 기록 | JudgmentStore | | `intention.cancel` | 취소 | 원래 수락 근거와 권한, 사용자 약속이면 사용자 확인 또는 원래 조건 충족 | `→ cancelled` | JudgmentStore. 사용자 약속의 취소는 채널 확인 receipt | | `intention.complete` | 완료 확정 | 완료 조건에 대응하는 실제 결과 참조 | `→ completed` | JudgmentStore. 결과 없는 완료는 거부 | -| `task.start` | 개발 작업 시작 | 작업 권한, 작업 내용, 관련 의도 참조 | 새 OMO 작업 thread | TaskManager receipt. 프로세스 종료는 목표 달성이 아님 | +| `task.start` | 개발 작업 시작 | 작업 권한, 작업 내용, 관련 의도 참조 | 새 Codex 작업 thread | TaskManager receipt. 프로세스 종료는 목표 달성이 아님 | | `task.send` / `task.interrupt` / `task.handover` | 진행 작업에 지시·중단·인계 | 작업 ID·현재 owner·revision | 작업 상태 변경 | TaskManager receipt | | `inquire` | 자료·기억·도구의 읽기 조회 | 조회 권한 | 없음. 예산만 소비 | Host 조회 receipt | | `defer` | 보류 + 재개 조건 | 조건 명시 | 없음 | 다음 회차 입력 | @@ -219,7 +219,7 @@ QA 채택 커널의 `Purpose`·`Adoption(understanding | plan | intention)`·`Ju ## 역할 프롬프트와 개선 루프 -엔진의 LLM 호출은 모두 버전 있는 **프롬프트 자산**을 사용한다. 프롬프트는 설정 파일이 아니라 메커니즘의 일부이며, 자산 revision은 각 판단의 `mechanismRevision`에 포함된다. 프롬프트가 바뀌면 같은 snapshot의 캐시된 평가는 재사용하지 않는다. 작성·개선 방법은 [PR #11의 방법론](https://github.com/thisisjun786/lina/blob/25346f15287a96d7e95e8c3e07c51fff1899f66f/docs/plans/platform/014_model_tuning_methodology_research.md)을 채택하되, 그 문서의 역할 표(라케시스=분석가, 아트로포스=결정자)와 "실행 엔진은 Codex" 문장은 이 정본의 목표 정의와 D13·D21이 대체한다. +엔진의 LLM 호출은 모두 버전 있는 **프롬프트 자산**을 사용한다. 프롬프트는 설정 파일이 아니라 메커니즘의 일부이며, 자산 revision은 각 판단의 `mechanismRevision`에 포함된다. 프롬프트가 바뀌면 같은 snapshot의 캐시된 평가는 재사용하지 않는다. 작성·개선 방법은 [PR #11의 방법론](https://github.com/thisisjun786/lina/blob/25346f15287a96d7e95e8c3e07c51fff1899f66f/docs/plans/platform/014_model_tuning_methodology_research.md)을 채택하되, 그 문서의 역할 표(라케시스=분석가, 아트로포스=결정자)는 이 정본의 목표 정의가 대체한다. 실행 엔진은 D22에 따라 Codex다. ### 프롬프트 자산과 층 @@ -253,7 +253,7 @@ QA 채택 커널의 `Purpose`·`Adoption(understanding | plan | intention)`·`Ju | 2. 원인 분류 | A 잘못된 정보(지침이 실제 포트·권한과 다름), B 잘못된 판단 기준(지침대로 했는데 원치 않는 행동), C 빠진 정보, 또는 프롬프트 밖 결함(전송 누락·schema·복원·정책·회로) | 해당 층·자산과 반대 설명. 프롬프트 밖 결함은 이 루프에서 제외 | | 3. 가설 작성 | 어떤 층의 어떤 조건을 바꾸면 어떤 결과가 달라지는지, 무엇이 관측되면 기각하는지 | 측정할 결과와 정상·경계 회귀 사례 | | 4. 최소 후보 작성 | 한 자산·한 층의 한 의미 변화. 목표 프로필 변경이 필요하면 D 번호 갱신과 함께 별도 처방으로 표시 | 기준안·후보 diff, 근거, 조립 입력의 토큰 차이 | -| 5. 구조·전송 확인 | 역할 선택, 층 중복·혼입, 필수 입력 존재, 실제 Senpi wire와 재개·압축 후 입력 | 구조 검사와 전송 capture. 품질 통과와 구분 | +| 5. 구조·전송 확인 | 역할 선택, 층 중복·혼입, 필수 입력 존재, 실제 Codex wire와 재개·압축 후 입력 | 구조 검사와 전송 capture. 품질 통과와 구분 | | 6. 개발 비교 | 고정 사례 × 반복에서 기준안·후보를 짝으로 비교. 상태 격리, 실행 순서 교대, 독립 judge | 사례별 승·패·동점·회귀·누락·장애·비용 | | 7. 고정 검증 | 개발에 쓰지 않은 사례에서 수정 없이 검증 | 채택·보류·기각과 G1–G8 충족 여부 | | 8. 기록과 적용 | 자산 revision, 증거, 되돌릴 revision 기록. 새 프리셋 revision과 binding generation으로 적용 | 진행 회차의 프롬프트를 바꾸지 않음 | @@ -264,23 +264,23 @@ QA 채택 커널의 `Purpose`·`Adoption(understanding | plan | intention)`·`Ju 엔진용 사례 범주는 목표 충돌·일치(같은 사실에서 세 추천이 갈리거나 같은 경우), 약속 보호(취향 반대와 `commitment_breach` 구분), 정정(최신 정정이 세 판단에 반영), 역할 분리(첫 출력 비노출, 다른 목표 흡수 없음), 누락·복원(한 역할 실패·늦은 결과·재시작 뒤), 정상 회귀(명확한 요청에 불필요한 재질문·보류 증가)다. 판정 기준은 [030 G1–G8](plans/context-engines/030_moirai_refactor_plan.md#검증-게이트)이 소유한다. -자동화하는 것은 실행·짝짓기·판정 호출·리포팅이다. 프롬프트 자동 생성·탐색, 후보 자동 승격, 통계적 유의성 인증은 자동화하지 않는다. 개발 비교에서 이긴 후보도 고정 검증과 프리셋 승인 없이는 제품에 들어가지 않는다. Senpi가 LLM 백엔드이므로 비교 harness는 Senpi의 evals 패턴(격리된 세션, 기준/후보 행 생성, 짝 집계)을 `scripts/qa/` 아래 엔진용으로 재사용할 수 있으며 제품 코드가 아니다. +자동화하는 것은 실행·짝짓기·판정 호출·리포팅이다. 프롬프트 자동 생성·탐색, 후보 자동 승격, 통계적 유의성 인증은 자동화하지 않는다. 개발 비교에서 이긴 후보도 고정 검증과 프리셋 승인 없이는 제품에 들어가지 않는다. 비교 harness는 Codex의 실제 전송을 관찰한다. Senpi 연구에서 확인한 격리 세션·짝 집계 방법론은 참고할 수 있으나 SDK 의존성이나 제품 백엔드 선택을 뜻하지 않는다. ## 저장소·프로세스·8명 운영 | 구성 요소 | 형태 | 소유 | | --- | --- | --- | | Host 조정기 | `lina-runtime` 안의 개인별 조정기. 개인·scope별 회차 lease와 큐 | 회차 순서·현재성·정책 실행·인계 | -| Senpi 역할 세션 | Moirai·Clotho·Lachesis·Atropos 네 역할. 검증된 프리셋의 모델·티어 배치. 역할 수와 모델 수는 같지 않음 | 의미 해석·계획·종합 텍스트 | +| Codex 역할 세션 | Moirai·Clotho·Lachesis·Atropos 네 역할. 검증된 프리셋의 모델·티어 배치. 역할 수와 모델 수는 같지 않음 | 의미 해석·계획·종합 텍스트 | | JudgmentStore | `state/judgment.sqlite`. 회차·판단·목표 프로필·`ResolutionRecord`·`SelectionSpec`·결정과 RNG·`IntentionRecord`·held/released outbox | 단일 writer는 Host | | NeuralPreferenceStore | `state/neural-preferences.sqlite`와 상태 blob. 관측·`h`/`m`/`ΔW`/trace·조회 receipt·학습 inbox | 단일 writer는 Host. Python은 결과를 제안만 | | Python 계산기 | 설치당 상주 프로세스 하나. 불변 `W0` 공유, 개인·scope별 상태·가중치·RNG stream 분리, 준비된 요청만 배치 | SDK와 독립된 포트 | | 기존 owner 저장소 | ConversationStore·EngineStore·AgentStore·WorldStore·TaskManager·게시·이미지 | 원본과 실제 효과 | -| OMO app-server | 설치당 하나의 `omo app-server` 프로세스(unix socket 또는 ws). Lina TaskManager가 `thread/*`·`turn/*`·`item/tool/call`·approval 메서드로 개발 작업을 실행하고, 프로바이더 계정·모델 목록·사용량은 `account/*`·`model/list`로 읽음 | 작업 thread와 프로바이더 자격 증명의 owner. Lina는 작업 ID·receipt·권한 정책과 검증된 프리셋만 소유 | +| Codex app-server·OpenCodex | 기존 `lina-codex` RPC·TaskManager가 작업 thread/turn과 도구·승인 수명을 연결. OpenCodex가 프로바이더 계정·카탈로그·사용량을 소유 | Lina는 작업 ID·receipt·권한 정책·검증된 프리셋을 소유. 프로세스·계정 운영은 기존 owner 계약 유지 | 두 저장소를 가로지르는 transaction은 없다. outbox/inbox와 receipt 대조로 중복을 막고, `decisionId → effectId → owner receipt → outcomeId`로 연결한다. checkpoint는 기존 owner가 조정하며 `CheckpointManifest`에 엔진 저장소·blob·대기 inbox/outbox를 함께 묶는다. manifest 확정 전 파일은 복원 가능한 checkpoint가 아니다. -8명은 정체성 8개다. 실제 상태 수는 공통 scope와 활성 세계 scope의 합이며 내부 역할 세션 수·비공개 scope 수와 같지 않다. 재시작 시 Host의 회차 ledger와 Senpi의 세션/run 상태를 대조하고, 응답 미수신만으로 같은 효과나 선택을 다시 시작하지 않는다. 정정·권한 회수·약속 변경·목표 프로필 변경은 큐를 기다리지 않고 진행 회차를 무효화한다. 단일 Python 프로세스 장애가 여러 개인의 신경 계산을 함께 멈출 수 있으므로 장애 시험을 F4에 포함한다. +8명은 정체성 8개다. 실제 상태 수는 공통 scope와 활성 세계 scope의 합이며 내부 역할 세션 수·비공개 scope 수와 같지 않다. 재시작 시 Host의 회차 ledger와 Codex의 thread/turn 상태를 대조하고, 응답 미수신만으로 같은 효과나 선택을 다시 시작하지 않는다. 정정·권한 회수·약속 변경·목표 프로필 변경은 큐를 기다리지 않고 진행 회차를 무효화한다. 단일 Python 프로세스 장애가 여러 개인의 신경 계산을 함께 멈출 수 있으므로 장애 시험을 F4에 포함한다. 성능 목표(신경 추가 지연 p95 100ms·deadline 250ms, 평균 2건/초·8건 동시 도착)는 **미측정 초기 목표**다. CPU 부분회로에서 먼저 측정하고 GPU는 동일 프로필·부하에서 실제 RAM/VRAM·처리량·오차를 비교한 뒤 결정한다. @@ -302,15 +302,17 @@ QA 채택 커널의 `Purpose`·`Adoption(understanding | plan | intention)`·`Ju | D10 | 목표 간 조정(`ArbitrationPolicy`·`BaselinePolicy`)과 신경 선호의 단일 반영(`p ∝ p0 × exp(λb)`)을 구분. `dimensionSource`로 성향 중복 생성 방지 | | D11 | 한 결과를 목표별 소비자에게 전달하고 각자 다르게 학습. 합의는 보상이 아님 | | D12 | 개인 연속성은 역할 세션 밖의 원본과 기록으로 유지. 개인·scope별 lease, 8명 = 정체성 8개 | -| D13 | LLM 백엔드는 Senpi. PR #10 역할 프롬프트는 F2에서 재작성 | +| D13 | 이전 결정: Senpi LLM 백엔드. 백엔드 선택은 D22가 대체한다. PR #10 역할 프롬프트를 고유 목표에 맞게 F2에서 다시 작성한다는 요구는 유지 | | D14 | MaleCNS 버섯체 부분회로는 엔진 완성의 필수 요소. 회로 프로필 v1의 인터페이스는 이 문서, 상수·세포 목록은 추출·측정 산출물 | | D15 | 개인 행동 catalog v1은 `intention.*`, `task.*`, `inquire`, `defer`, `noop`. 기억·선호·성장 저장은 catalog 밖 | | D16 | `IntentionRecord` v1 schema와 여섯 가지 재고 조건. `completed`는 결과 참조, `cancelled`는 수락 근거 참조 필수 | -| D17 | `personal.v1` 조정 정책: Host 적격성 → 약속 보호 → 실행 불가 제외 → 상황별 목표 순서 → rank-mass `p0`(ratio 0.5) → `λ`(user_request 0, 그 외 1). 값은 policy revision 1의 선언값 | +| D17 | `personal.v1` 정책의 상황별 목표 순서·rank-mass `p0`(ratio 0.5)·`λ`(user_request 0, 그 외 1)를 유지. 제외 근거와 판단 불가 처리는 D23 및 아래 현재 정책이 소유 | | D18 | `PersonaSchema`의 소유자는 AgentStore. World는 자기 축을 schema dimension에 매핑. NativePersonaGrowth는 LifeDefinition 대신 schema를 읽음 | | D19 | 저장소는 `JudgmentStore`·`NeuralPreferenceStore`·기존 owner 셋. 교차 transaction 없음, outbox/inbox·receipt로 연결. Python 계산기는 설치당 하나 | | D20 | 프롬프트는 버전 있는 자산이며 `mechanismRevision`에 포함. 다섯 층 분리, 역할 계약은 `ObjectiveProfile`에서 생성. 개선은 PR #11 방법론의 8단계 루프(실패 고정 → A/B/C 분류 → 최소 후보 → 구조·전송 확인 → 짝 비교 → 고정 검증 → 프리셋 revision 적용)로만 수행. 자동 생성·자동 승격 없음 | -| D21 | 개발 작업 실행 엔진은 OMO native(`omo app-server`), 프로바이더 계정·모델 목록·인증·사용량 관리는 Senpi native. Codex CLI와 OpenCodex Hub는 전환 완료 후 폐기. 근거: `lina-codex`가 사용하는 app-server 메서드 26개(`thread/*`, `turn/*`, `item/tool/call`, `item/*/requestApproval`, `model/list`, `skills/*`)가 Senpi app-server에 모두 있어 기존 `TaskManager`의 작업 ID·receipt·권한 계약을 유지한 채 백엔드만 교체 가능하고, 인지·작업·프로바이더가 한 런타임을 공유한다. 이 결정은 이전에 Senpi/OmO 경로를 퇴역시킨 결정을 명시적으로 뒤집는 것이며, 예전 `lina-jobs` 코드를 복원하는 것이 아니라 app-server 프로토콜과 Senpi SDK로 새 어댑터를 만드는 것이다 | +| D21 | 이전 결정: OMO native 작업 실행·Senpi native 프로바이더 관리로 옮기고 Codex/OpenCodex 폐기. 2026-09-13 D22로 철회. 과거 QA와 자료는 근거 이력으로 보존 | +| D22 | 2026-09-13 소유자 결정: 인지·대화·개발 작업은 Codex, 프로바이더 관리는 OpenCodex로 유지한다. D13·D21의 Senpi/OMO 전환을 철회한다. 세 목표·MaleCNS·Host 소유권은 유지하며 기존 실행 경로에 연결한다 | +| D23 | 출력은 생성 토큰 예산과 저장용 문장 길이를 분리한다. 새 설명문은 길이 때문에 판단 전체를 실패시키지 않고 자른 표시·원래 길이·해시를 남긴다. 구조화된 필수 판단은 자르지 않는다. policy revision 2는 필수 평가 불가 시 보완 후 보류, 예산 소진 시 deferred를 사용하고 revision 1은 과거 재생으로 보존 | ## 이 문서가 확정하지 않는 것 @@ -324,8 +326,8 @@ QA 채택 커널의 `Purpose`·`Adoption(understanding | plan | intention)`·`Ju | 제품 프리셋의 모델 배치 | [030 모델 운영](plans/context-engines/030_moirai_refactor_plan.md#모델-운영-검증한-프리셋으로-제한) | | 프롬프트 비교의 judge 모델·rubric·반복 수·채택 임계값 | 실험 명세마다 선언. 이 문서는 절차와 독립성 조건만 확정 | | 네 역할 프롬프트의 실제 본문 | F2 산출물. 첫 revision은 위 층 구조와 `ObjectiveProfile`에서 작성하고 구조·전송 검사를 통과해야 함 | -| Codex·OpenCodex 폐기 시점, 기존 세션 binding·`models.sqlite`·작업 이력의 이전 | F4 운영 전환. 자동 이전·자동 삭제 없음, 기존 데이터는 보존하고 전환 필요 상태로 표시 | -| OMO app-server의 `dynamicTools`·approval·transport(unix/ws)·daemon 수명의 실제 검증 | F2에서 실제 소스/built SDK 전송 검사로 확인. 메서드 이름 일치는 의미 일치의 증거가 아님 | +| 기존 세션 binding·`models.sqlite`·작업 이력 | 기존 Codex/OpenCodex owner에서 보존. 폐기·이전은 계획하지 않으며 F4에서 새 판단 저장소와 함께 복구 검사 | +| Codex 역할 thread·도구·approval·출력 예산의 실제 연결 | F2 제품 입력·실전송·재개 검사. 이전 R0 QA는 typed 판단 계약의 제품 통합 증거가 아님 | ## 관련 문서 diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 0d9deec..e4fa0c5 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -20,16 +20,16 @@ | 세계 배경 편집과 규칙 미리보기 | [세계 편집 계약](plans/life/020_world_authoring.md) | 원문·질문·초안 버전 저장, 명시적 확인, 제한된 lore/규칙 평가와 전용 작성 세션 구현. 종료 경합 수정과 독립 검토 통과. 실제 모델 품질·UI는 별도 검증 | | LIFE 사회 시뮬레이션과 공유 성장 | [소스 분석과 구현 방향](plans/platform/013_life_engine_research.md) | 사용자 배경 설정·확률적 사건·업무 영향·개인 경험·비밀·관계가 목표. 성격·관계 성장은 일반 대화와 공유하고 사건 원문은 별도 공개 제어. [사회 엔진](plans/life/030_social_engine.md)의 규칙 판정·비밀 전달·저장·복구와 설치 패키지 연결 구현, 독립 검토 통과. [자율 일상](plans/life/040_autonomous_life.md)의 실행·복구·모델 격리·설치본 HTTP 검증과 독립 검토 통과 | | LIFE 전체 구현과 통합 검증 | [전체 계획](plans/life/000_plan.md), [첫 단위 저장 설계](plans/life/011_state_contract.md), [대화 이전 설계](plans/life/012_context_migration.md), [이미지·아바타 연결](plans/life/070_images_and_avatars.md) | 8단계 로컬 엔진 구현·검증 완료. 010~050 저장·세계 편집·사회 엔진·자동 일상·업무·기억 연결 검증 통과. 060 게시물·상호작용도 독립 재검토, 전체 테스트와 실제 HTTP 검증 통과. 070 이미지·아바타 연결도 완료. 080의 단일 설치 통합 흐름·저장소 장애 격리·체크포인트 복원과 [소비자 계약](LIFE_ENGINE.md) 검증 통과. 최종 전체 테스트 3,368개·독립 리뷰 통과 | -| 모이라이 엔진 | [정본 MOIRAI_ENGINE](MOIRAI_ENGINE.md), [계약 016](plans/platform/016_neural_preference_contract.md), [로드맵 017](plans/platform/017_moirai_module_composition.md), [근거 015](plans/platform/015_neural_preference_engine_research.md), [운영 030](plans/context-engines/030_moirai_refactor_plan.md) | 확정 설계. 클로토는 미래 성과, 라케시스는 욕구·선호 충족, 아트로포스는 의도·연속성을 각각 추구하고 모이라이가 `personal.v1` 조정 정책으로 종합. 행동 catalog v1, `IntentionRecord` v1, MaleCNS 회로 프로필 v1 인터페이스, 프롬프트 자산·개선 루프(D20), OMO native 작업 실행·Senpi native 프로바이더 관리(D21)까지 확정. Senpi 백엔드, MaleCNS 필수, Codex CLI·OpenCodex는 전환 후 폐기. 구현·판단 효용·회로 성능은 F1–F4에서 검증 | +| 모이라이 엔진 | [정본 MOIRAI_ENGINE](MOIRAI_ENGINE.md), [계약 016](plans/platform/016_neural_preference_contract.md), [로드맵 017](plans/platform/017_moirai_module_composition.md), [근거 015](plans/platform/015_neural_preference_engine_research.md), [운영 030](plans/context-engines/030_moirai_refactor_plan.md) | F1 공통 계약 구현. 세 고유 목표와 MaleCNS 필수 요구 유지. D22는 Codex 인지·작업과 OpenCodex 프로바이더 연결을 선택하고 Senpi/OMO 전환을 철회. D23은 설명문 출력 제한·잘림과 판단 불가 시 보완/보류를 선언. 제품 연결·판단 효용·회로 성능은 F2–F4에서 검증 | UI 공개 참고 자료는 [설계 참고 자료](plans/codex-ui/000_source_research.md), 세부 시각 기준은 [Codex 디자인 언어](plans/codex-ui/005_codex_design_language.md)에 있다. 개인 캡처와 운영 이력은 제품 소스에 포함하지 않는다. ## 현재 구현과 계획의 관계 - **첫 시작:** 사용자 소개는 최초 한 번만 한다. 사이드바 없는 대화 화면에서 이름/호칭과 기본 맥락을 묻고, 완료 후 재진입 메뉴를 제공하지 않는다. 에이전트 추가는 리나가 진행하는 별도 생성 대화다. [완료된 흐름](plans/onboarding.md)을 유지한다. UI 기획의 목록·뒤로 가기·설정은 평소 대화에 적용하며 최초 소개로 복귀하는 기능을 되살리지 않는다. -- **런타임과 패키지:** 전환 전 dev의 실행 엔진은 Codex, 프로바이더 관리는 OpenCodex다. 모이라이 코어의 선택된 백엔드는 새 Senpi SDK이며 [제품 연결 계약](plans/platform/016_neural_preference_contract.md#결정과-현재-연결-지점)에 따라 전환한다. `lina-runtime`은 제품 런타임을 소유한다. 개발 작업 실행은 `omo app-server`, 프로바이더 관리는 Senpi native로 옮기고 Codex CLI·OpenCodex는 전환 완료 후 폐기한다([정본 D21](MOIRAI_ENGINE.md#확정된-결정-목록)). 이는 Senpi/OmO 경로를 퇴역시킨 이전 결정을 명시적으로 뒤집는 것이지만, 과거에 제거된 `lina-jobs` 코드를 복원하는 것이 아니라 app-server 프로토콜과 Senpi SDK로 새 어댑터를 만드는 것이다. 계획의 제안 API·테이블·패키지는 구현 전에 현재 코드와 대조한다. +- **런타임과 패키지:** 현재와 계획의 인지·작업 백엔드는 Codex, 프로바이더 owner는 OpenCodex다([정본 D22](MOIRAI_ENGINE.md#확정된-결정-목록)). `lina-runtime`이 제품 회차를 조정하고 기존 `lina-codex`·TaskManager를 연결한다. Senpi/OMO 전환·Codex/OpenCodex 폐기는 철회하며 기존 세션·작업 데이터는 유지한다. 계획의 제안 API·테이블은 구현 전에 현재 코드와 대조한다. - **설치 홈:** 명시한 `LINA_HOME`, 기본 `~/.lina`가 기준이다. Lina OS의 초기 `/var/lib/lina` 제안은 현재 기본 경로를 바꾸지 않는다. OS 서비스 등록은 사용자 데이터와 구분한다. -- **모델 연결과 OS 설정:** Lina가 검증 모델 프리셋과 역할·티어 배치를 소유하고, 프로바이더 계정·인증·모델 목록·사용량은 Senpi native가 소유한다(D21). 사용자가 백엔드와 역할별 모델을 자유롭게 조립하는 안은 취소했다. 현재 설정 API의 전환은 모이라이 F2의 계약 적용과 F4의 운영 전환에 포함한다. Tailscale·도구 설치와 OS 설정 완료 기준은 [OS 제품 경계](REPOSITORY_SPLIT.md)가 정한 OS 소유 범위다. 네이티브 Lina의 첫 소개에 OS 설치 요구를 강제하지 않는다. +- **모델 연결과 OS 설정:** Lina가 검증 모델 프리셋과 역할·티어 배치를 소유하고, 프로바이더 계정·인증·모델 목록·사용량은 OpenCodex가 소유한다(D22). 사용자가 백엔드와 역할별 모델을 자유롭게 조립하는 안은 취소했다. 현재 설정 API의 전환은 모이라이 F2의 계약 적용과 F4의 운영 전환에 포함한다. Tailscale·도구 설치와 OS 설정 완료 기준은 [OS 제품 경계](REPOSITORY_SPLIT.md)가 정한 OS 소유 범위다. 네이티브 Lina의 첫 소개에 OS 설치 요구를 강제하지 않는다. - **데이터 이력:** 원래 요구는 페르소나뿐 아니라 메모리·에이전트 설정·사용자 프로필 전체다. 현재 체크포인트는 오프라인 로컬 스냅샷과 선택적 Git 명세 기록이다. 자동 변경 이력, 선택 복원, 외부 저장소까지 일관된 복구가 구현됐다고 표현하지 않는다. ## 실행 환경 소유권 diff --git a/docs/plans/context-engines/030_moirai_refactor_plan.md b/docs/plans/context-engines/030_moirai_refactor_plan.md index 16ea173..9e4d237 100644 --- a/docs/plans/context-engines/030_moirai_refactor_plan.md +++ b/docs/plans/context-engines/030_moirai_refactor_plan.md @@ -1,6 +1,6 @@ # 모이라이 시스템 리팩토링 계획 -상태: 2026-09-12. 모이라이 엔진의 사용자 경험 경계, 채널·세션 계약, 도메인 통폐합, 모델 프리셋 운영, 검증 게이트를 소유한다. 엔진 정의·세 판단 모듈·조정 정책·회차 순서는 정본 [MOIRAI_ENGINE](../../MOIRAI_ENGINE.md)이, 계약은 [016](../platform/016_neural_preference_contract.md)이, 로드맵은 [017](../platform/017_moirai_module_composition.md)이, 근거는 [015](../platform/015_neural_preference_engine_research.md)가 소유한다. 선택된 LLM 백엔드는 Senpi다. [R0의 Codex 실행 증거](031_moirai_r0_evidence.md)는 이전 QA 기록이며 현재 목표의 제품 통합·판단 성능·qualification 증거가 아니다. +상태: 2026-09-13. 모이라이 엔진의 사용자 경험 경계, 채널·세션 계약, 도메인 통폐합, 모델 프리셋 운영, 검증 게이트를 소유한다. 엔진 정의·세 판단 모듈·조정 정책·회차 순서는 정본 [MOIRAI_ENGINE](../../MOIRAI_ENGINE.md)이, 계약은 [016](../platform/016_neural_preference_contract.md)이, 로드맵은 [017](../platform/017_moirai_module_composition.md)이, 근거는 [015](../platform/015_neural_preference_engine_research.md)가 소유한다. 선택된 인지·작업 백엔드는 Codex이고 프로바이더 owner는 OpenCodex다(D22). [R0의 Codex 실행 증거](031_moirai_r0_evidence.md)는 이전 QA 기록이며 현재 목표의 제품 통합·판단 성능·qualification 증거가 아니다. ## 최상위 어젠다 @@ -21,7 +21,7 @@ - [PR #10](https://github.com/thisisjun786/lina/pull/10): 고정 리비전의 Senpi QA와 역할 프롬프트 실증. [016 연결 지점](../platform/016_neural_preference_contract.md#결정과-현재-연결-지점)의 자료를 기준으로 제품 계약을 구현한다. - 이 계획: 기존 도메인 소유권과 한 채널의 내부 판단 수명을 정한다. PR #8의 코드를 복사하거나 qualification을 대체하지 않는다. -제품 코드 연결 지점의 기준은 `522101ff99e356b0ea6d27b4ea03ec7e599ee4b3`, 별도 채택 커널의 기준은 PR #8의 `ab9f1f073eca80ecef8dda0b0f7d338f4d6cb35c`다. 기존 `packages/lina-codex/src/moirai-probe*.ts`는 이전 Codex QA 증거로 보존하고 새 Senpi 제품 조정기로 복제하지 않는다. 설치/checkpoint 소유자는 재사용하며 코어·신경 상태의 일관된 snapshot 계약을 확장한다. 아래 신규 계약은 현재 구현된 공통 API로 간주하지 않는다. +제품 코드 연결 지점의 기준은 `522101ff99e356b0ea6d27b4ea03ec7e599ee4b3`, 별도 채택 커널의 기준은 PR #8의 `ab9f1f073eca80ecef8dda0b0f7d338f4d6cb35c`다. 기존 `packages/lina-codex/src/moirai-probe*.ts`는 이전 Codex QA 증거로 보존하고 제품 조정기로 복제하지 않는다. 설치/checkpoint 소유자는 재사용하며 코어·신경 상태의 일관된 snapshot 계약을 확장한다. 아래 신규 계약은 현재 구현된 공통 API로 간주하지 않는다. ## 판단 계층 @@ -48,7 +48,7 @@ flowchart TD M --> D[일반 대화: Host 검증 뒤 응답] ``` -채널은 사용자 대화 식별자다. Senpi의 내부 역할 세션은 채널·인격·OS 프로세스와 일대일이 아니다. LLM은 모듈의 의미 해석과 계획 생성에 쓰며 각 모듈의 코드·기억 조회·수치 계산을 대체하지 않는다. 원문과 현재 채택 상태는 LINA가 소유한다. +채널은 사용자 대화 식별자다. Codex의 내부 역할 thread은 채널·인격·OS 프로세스와 일대일이 아니다. LLM은 모듈의 의미 해석과 계획 생성에 쓰며 각 모듈의 코드·기억 조회·수치 계산을 대체하지 않는다. 원문과 현재 채택 상태는 LINA가 소유한다. | 회차 계약 | 내용 | | --- | --- | @@ -64,7 +64,7 @@ flowchart TD 개인·scope별 회차 lease와 큐가 순서를 정하고 회차 내부의 독립 계산은 병렬로 수행한다. 정정·권한 회수는 큐를 기다리지 않고 진행 중 회차를 무효화한다. lease가 끝난 이전 실행의 결과는 fencing token으로 거부한다. 외부 효과 완료를 기다리는 동안 회차 lease를 점유하지 않는다. -재시작 시 Host의 회차 ledger와 Senpi가 제공하는 세션/run 상태를 대조한다. 응답 미수신만으로 같은 효과나 선택을 다시 시작하지 않는다. binding과 모델 변경은 generation을 올리고 이전 이력을 보존한다. Senpi의 지원 범위는 제품 어댑터에서 검증하며 Codex thread 제약이나 nativeEpoch를 그대로 이식하지 않는다. +재시작 시 Host의 회차 ledger와 Codex가 제공하는 thread/turn 상태를 대조한다. 응답 미수신만으로 같은 효과나 선택을 다시 시작하지 않는다. binding과 모델 변경은 generation을 올리고 이전 이력을 보존한다. 기존 Codex thread·nativeEpoch·출처 계보 계약을 재사용하되 typed 판단의 실제 재개와 격리를 제품 어댑터에서 검증한다. 세션 자체 이력이나 compaction만으로 필수 입력을 전달했다고 가정하지 않는다. 원문·정정·현재 의도·이전 결과의 snapshot과 실제 전송을 남긴다. 배치·프로세스 수는 이 수명과 관측 계약을 충족하는 배포 방식으로 선택한다. 수치 계산은 SDK 밖의 독립 포트다. @@ -100,9 +100,9 @@ flowchart TD 실행 시 UI뿐 아니라 설정 API·저장된 설정·실제 송신 직전에도 프리셋과 모델을 확인한다. 지원 밖 모델, 사용 불가능한 필수 모델, 달라진 설정은 실행 전에 거부한다. 프리셋 내부에 검증된 대체 경로가 없다면 다른 모델로 조용히 전환하지 않는다. 프리셋 변경은 진행 회차의 모델을 바꾸지 않고 새 binding generation에 적용한다. 기존 사용자 모델 설정과 이력은 보존하고 지원 프리셋 전환이 필요한 상태로 처리한다. 자동 삭제·자동 원격 호출은 하지 않는다. -프로바이더 관리는 [정본 D21](../../MOIRAI_ENGINE.md#확정된-결정-목록)에 따라 Senpi native다. 계정 로그인·failover·pin·제거, 모델 목록, rate limit·사용량 조회는 app-server의 `account/*`·`model/list`·`config/read`를 사용하고, 자격 증명은 Senpi의 agent dir(`auth.json`/`oauth.json`)에 남는다. Lina는 개별 프로바이더 자격 증명을 저장하지 않으며 검증된 프리셋과 역할·티어 배치만 소유한다. OpenCodex Hub의 카탈로그·관리 GUI·환경 변수(`LINA_OPENCODEX_*`)는 전환 완료 후 제거한다. +프로바이더 관리는 [정본 D22](../../MOIRAI_ENGINE.md#확정된-결정-목록)에 따라 OpenCodex가 소유한다. 기존 계정·카탈로그·사용량·관리 화면과 `LINA_OPENCODEX_*` 연결을 유지한다. Lina는 개별 프로바이더 자격 증명을 복사하지 않고 검증된 프리셋과 역할·티어 배치를 소유한다. Senpi native 계정으로 옮기는 D21 계획은 철회했다. -이는 후속 제품 계약이다. 현재 제품의 자유 profile/tier 설정 API와 OpenCodex 연결이 제거됐다는 뜻은 아니다. F2에서 역할·전송 계약을 적용하고 F4의 운영 전환에서 현재 `models/types.ts`, `validation.ts`, `settings.ts`, `selection.ts`, 설정 HTTP/UI와 Senpi 송신 소비자, `lina-opencodex` 소비자를 함께 전환한다. 이전 모델 온보딩 문서의 자유 선택 제안보다 이 계약을 우선한다. +검증 프리셋은 후속 제품 계약이다. F2에서 Codex 역할·전송 계약을 적용하고 F4에서 `models/types.ts`, `validation.ts`, `settings.ts`, `selection.ts`, 설정 HTTP/UI와 기존 OpenCodex 송신 소비자를 함께 검증한다. 현재 자유 profile/tier 설정이 이미 제거됐다는 뜻은 아니다. ## 판단·효과·의미 복구 @@ -124,7 +124,7 @@ flowchart TD - **G2 불변조건:** 원본 현재성, 권한, 단일 writer, 회차 격리, 효과 중복 방지, 부분 실패, 실제 프로세스 중단 후 실행/의미 복구를 검증한다. - **G3 기존 qualification:** PR #8의 기준 리비전 `ab9f1f073eca80ecef8dda0b0f7d338f4d6cb35c`에서 출발한 30개 기준, 중요 H 조건 전부, 전체 90점 이상·카테고리별 80점 이상, 고정 후보의 새 전체 배치 3회 연속을 유지한다. 새 표현과 기존 채점 계약의 호환이 안 되면 미충족으로 기록하며 기준을 약화하지 않는다. 후속 실행 전에 시나리오·scorer·실행 후보의 정확한 리비전과 변경점을 함께 고정하고, 로컬 후속 후보를 암묵적으로 같은 채점기로 취급하지 않는다. - **G4 추가 효용:** baseline/kernel/이해 제거군의 자원을 맞추고, 동일 전문 지원과 총자원의 단일 판단·재검토 경로와 MoA도 별도로 비교한다. 동점이면 추가 효용 미입증이다. 정답 누출·사례별 답 하드코딩을 금지한다. -- **G5 비용:** 예전 하네스의 episode 6회·출력 4096토큰·120초는 해당 고정 실험의 조건이며 제품 출력 제한으로 복사하지 않는다. 대화 3+1·행동 3+2에 추가 평가·조회·재시도·신경 계산을 합산한다. 제품 원문을 잘라 예산을 맞추지 않으며 동일 총자원에서 실제 품질·지연을 비교한다. +- **G5 비용:** 예전 하네스의 episode 6회·출력 4096토큰·120초는 해당 고정 실험의 조건이며 제품 출력 제한으로 복사하지 않는다. 대화 3+1·행동 3+2에 추가 평가·조회·재시도·신경 계산을 합산한다. 사용자 원문·정정·권한은 보존한다. D23의 역할별 생성 토큰 예산은 실제 전송에서 검사하고, 설명문 잘림과 불완전한 구조화 출력을 구분한다. 잘린 부분의 원래 길이·해시·잘림 표시를 보존하고 동일 총자원에서 실제 품질·지연을 비교한다. - **G6 문서·호환:** 구현 전후, 로컬/원격 CI/실모델 증거를 구분한다. 실제 구현 시 AGENTS와 README의 해당 계약을 반영하며, PR #8과 이 계획의 완료 상태를 혼동하지 않는다. - **G7 자연스러운 대화:** 단순 지시·정정·감정 대화에서 내부 코어·저장 절차를 중계하지 않고 해당 의도를 행동에 반영하는지 실모델로 확인한다. 실패·기억 한계는 사실대로 말하고, 구조를 직접 묻는 질문에는 검증된 설명을 제공한다. 단어 금지 검사만으로 통과시키지 않는다. - **G8 프리셋 경계:** UI를 거치지 않는 API 입력, 구형 저장 설정, 지원 모델 누락, 실행 중 설정 변경, native/provider의 임의 fallback을 포함해 지원 조합 밖 송신이 0회인지 검증한다. 새 프리셋 revision은 자체 호환·인지 평가를 통과해야 제품에 추가한다. @@ -134,7 +134,9 @@ R0는 합성 입력을 사용하는 QA 경로로 구현됐다. GLM 실제 호출 ## 채널·실행 엔진 전환의 연결 지점 -전환 대상은 Codex CLI → `omo app-server`(작업 실행), OpenCodex Hub → Senpi native(프로바이더·모델)다. `lina-codex`가 현재 호출하는 app-server 메서드(`thread/start|read|resume|compact/start|name/set|turns/list`, `turn/start|steer|interrupt`, `item/tool/call`, `item/commandExecution|fileChange|permissions/requestApproval`, `model/list`, `skills/list|extraRoots/set`)는 Senpi app-server에 동일 이름으로 존재한다. 이름 일치는 F2의 실전송 검사 대상이며 의미 일치의 증거가 아니다. 저장소 루트 기준: +전환은 백엔드 교체가 아니라 기존 Codex 대화에 모이라이 판단 계약을 연결하는 작업이다. + +D22는 기존 Codex app-server와 OpenCodex를 유지한다. F2는 아래 연결 지점에 typed 판단과 역할 격리·도구·승인·출력 예산을 적용한다. R0 QA와 기존 일반 대화 성공은 새 모이라이 제품 회차의 호환 증거를 대신하지 않는다. 저장소 루트 기준: - `packages/lina-codex/src/tasks/protocol.ts` — thread start/read/resume와 turn 식별. - `packages/lina-codex/src/tasks/native.ts` — thread별 이벤트 식별. diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index 9a6db90..f922f6c 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -1,19 +1,19 @@ # 모이라이 코어의 판단·선택·학습·실행 계약 -상태: 2026-09-12 확정 계약. 정본 [MOIRAI_ENGINE](../../MOIRAI_ENGINE.md)의 세 판단 모듈·조정 정책·행동 catalog·의도 schema·회로 프로필을 입력·계산·결과·학습·저장·복구 계약으로 구체화한다. 타입·포트는 F1–F2가 구현할 계약이며 현재 코드나 Senpi QA가 이 구조를 구현했다는 뜻이 아니다. [015](015_neural_preference_engine_research.md)는 근거와 출처를, [017](017_moirai_module_composition.md)은 소스 재료·F1–F4 로드맵·검증 가설을 소유한다. +상태: 2026-09-13 확정 계약(D22·D23 반영). 정본 [MOIRAI_ENGINE](../../MOIRAI_ENGINE.md)의 세 판단 모듈·조정 정책·행동 catalog·의도 schema·회로 프로필을 입력·계산·결과·학습·저장·복구 계약으로 구체화한다. F1 공통 레코드·파서·정책·저장소는 구현됐고 F2 포트·메커니즘·제품 연결은 남아 있다. Senpi QA는 제품 통합 증거가 아니다. [015](015_neural_preference_engine_research.md)는 근거와 출처를, [017](017_moirai_module_composition.md)은 소스 재료·F1–F4 로드맵·검증 가설을 소유한다. 모이라이는 한 개인 안에서 서로 다른 목표를 추구하는 세 판단을 종합한다. 클로토는 미래 성과·성장 가능성, 라케시스는 자신의 욕구·선호 충족, 아트로포스는 채택한 목표·약속·정체성의 연속성을 우선한다. 라케시스의 학습된 선호는 Google Research가 소개한 **MaleCNS v1.0** 부분회로로 구현한다. 개인 대화·학습·LIFE 선택·재시작·8명 운영을 연결하며 비교 실험은 구현을 선택하는 증거로 사용한다. ## 결정과 현재 연결 지점 -기존 코드 연결 지점은 `522101ff99e356b0ea6d27b4ea03ec7e599ee4b3`, 인지 확장 기준은 PR #10의 `5b22aee53f9f7c01cc508289099f662aed613140`이다. **이 설계의 선택된 대화·인지 백엔드는 새 Senpi SDK**다. PR #10에는 Senpi SDK 생성·대화·도구·취소·재개 검증과 영어 인지 프롬프트가 들어 있다. 일반 SDK 시험과 도구 없는 Moirai 3+1 시험은 별도 시나리오다. 같은 PR에 남은 ‘실행 엔진 재검토’ 문구보다 이번 Senpi 선택을 설계 기준으로 우선한다. 기존 dev의 Codex 코드와 정책은 전환 전 연결 지점을 찾는 근거이며, 이 문서가 Senpi 제품 통합 완료를 증명하지는 않는다. +현재 F1 계약 기준은 PR #14의 `13ff9b2f0b243d1b7adaa6eabd59d4ad66272da8`이다. **대화·인지·개발 작업 백엔드는 Codex, 프로바이더 관리는 OpenCodex**로 유지한다(D22). PR #10의 Senpi QA는 비교 자료로 보존하며 제품 어댑터의 출발점이나 통합 완료 증거로 취급하지 않는다. F2는 기존 Codex RPC·SessionPort·TaskManager 경계에 typed 판단을 연결한다. | 현재 소유자 | 확인한 책임 | 제안하는 변경 | | --- | --- | --- | | [개인 성장 생성](../../../packages/lina-runtime/src/persona/native-growth.ts#L161) → AgentStore | 허용된 경험의 모델 해석을 성향 값으로 저장 | 지정 dimension의 생성자를 기존 해석 또는 신경 투영 중 하나로 선택 | | [성향 합성](../../../packages/lina-core/src/agents/persona.ts#L53) → 대화·LIFE | 정체성·lock을 지키며 허용된 현재 성향 제공 | 새 신경 출처의 유효성·revision을 확인한 투영만 소비 | | [Moirai 입력 조립](https://github.com/thisisjun786/lina/blob/5b22aee53f9f7c01cc508289099f662aed613140/scripts/qa/senpi-sdk/moirai-runner.ts#L145) | 원래 대화와 완결된 익명 조언 원문 | 원문을 보존하는 typed assessment, 공통 후보 평가, 행동 prepare/finalize 모드 | -| [제품 세션 조립](../../../packages/lina-runtime/src/session-app.ts#L500)·[SessionPort](../../../packages/lina-runtime/src/sdk-port.ts#L27) | 기존 실행 세션을 DurableRuntime에 연결 | Senpi 어댑터와 제품 회차 조정기가 내부 조언·종합을 수행하고 하나의 논리 대화 포트만 외부에 노출 | +| [제품 세션 조립](../../../packages/lina-runtime/src/session-app.ts#L500)·[SessionPort](../../../packages/lina-runtime/src/sdk-port.ts#L27) | 기존 실행 세션을 DurableRuntime에 연결 | Codex 어댑터와 제품 회차 조정기가 내부 조언·종합을 수행하고 하나의 논리 대화 포트만 외부에 노출 | | [대화 기록·정착](../../../packages/lina-runtime/src/runtime.ts#L182)·[출처 결합 저장](../../../packages/lina-core/src/store.ts#L300) | 응답 entry와 request의 출처 연결·settlement | 수락한 최종 응답·DialogueJudgmentRef·학습 TraceRef 결합을 대화 owner가 원자적으로 기록 | | [LIFE director](../../../packages/lina-runtime/src/life/director.ts#L82)·[영속화](../../../packages/lina-core/src/world/autonomy-persistence.ts#L513) | actor·target·reflection, prepare/reconcile | 확정 결정 참조와 실행 가능 상태를 소비; 순수 재계산에서 신경 상태를 진행하지 않음 | @@ -21,13 +21,13 @@ 아래 명세는 기존 공개 API를 즉시 바꾸지 않는다. 후속 구현은 타입 버전, source proof, 저장 복구와 각 소비자를 같은 단위에서 변경한다. -Senpi 연결은 PR #10의 [세션 생성](https://github.com/thisisjun786/lina/blob/5b22aee53f9f7c01cc508289099f662aed613140/scripts/qa/senpi-sdk/live-session.ts#L72)과 [잠근 의존성](https://github.com/thisisjun786/lina/blob/5b22aee53f9f7c01cc508289099f662aed613140/scripts/qa/senpi-sdk/package.json#L13)을 출발점으로 한다. 확인한 버전은 `@code-yeongyu/senpi@2026.9.10-2`이며 `createAgentSession`, `ModelRuntime`, `SessionManager`를 제품 어댑터 안에서 사용한다. QA의 capture provider·임시 경로·고정 모델·도구 권한을 제품에 복사하지 않는다. Senpi 세션의 수명·응답·usage를 Host 포트에 대응시키고 원래 request·source 연결은 Host가 소유한다. 신경 계산기는 SDK와 독립된 포트 뒤에 둔다. +Codex 연결은 기존 `lina-codex/src/session.ts`, `rpc.ts`, `tasks.ts`와 제품 `SessionPort`를 재사용한다. R0의 `moirai-probe*`는 역할 thread·재개·출처 검사의 참고 자료다. QA capture provider·임시 경로·고정 모델·도구 권한을 제품에 복사하지 않는다. Codex thread/turn 수명·응답·usage를 Host 포트에 대응시키고 원래 request·source 연결은 Host가 소유한다. 신경 계산기는 실행 SDK와 독립된 포트 뒤에 둔다. ## 세 판단 모듈의 계약 분리 기준은 각 모듈의 고유 목표와 답을 비교하는 기준이다. 목표 정의는 [정본의 세 판단 모듈](../../MOIRAI_ENGINE.md#세-판단-모듈)을 따른다. 상태·계산·결과 확인 방법은 그 목표를 판단하는 수단이다. 세 모듈에 같은 목표를 주고 근거만 달리 읽히는 구조나 계획/점수/승인 기능만 분업하는 구조로 축소하지 않는다. 기억·정정·권한 원본은 공유한다. -의존 방향은 `Host → 판단 포트 → 허용된 기억/목표/계산 포트`다. Senpi는 LLM 호출 어댑터, Python은 라케시스의 수치 계산 어댑터다. 모듈은 서로의 내부 상태를 수정하지 않는다. 기존 QA의 `proposals: string[]`에서 아래 버전 있는 판단 계약으로 옮기는 변경은 제품 Senpi 연결과 소비자 검증을 함께 요구한다. +의존 방향은 `Host → 판단 포트 → 허용된 기억/목표/계산 포트`다. Codex는 LLM 호출 어댑터, Python은 라케시스의 수치 계산 어댑터다. 모듈은 서로의 내부 상태를 수정하지 않는다. 기존 QA의 `proposals: string[]`에서 아래 버전 있는 판단 계약으로 옮기는 변경은 제품 Codex 연결과 소비자 검증을 함께 요구한다. ```text PromptAsset = { promptId, revision, role, layerHashes: { common, role, model }, @@ -39,7 +39,7 @@ PromptRun = { runId, attemptId, configuration: baseline | candidate, caseId, rep wireCaptureRef, outcomeRefs, judgeScore | unscorable, usage, elapsed, terminationReason } ``` -`mechanismRevision`은 프롬프트 자산 revision, encoder/readout 버전, 정책 코드 버전을 포함하는 digest다. 역할 자산의 역할 계약 층은 `ObjectiveProfile`에서 생성하므로 프로필 revision 변경은 자산 revision 변경이다. 자산 변경은 새 revision과 새 binding generation으로만 적용하고 진행 회차의 프롬프트를 바꾸지 않는다. 실험은 완전한 쌍만 품질 비교에 넣고 누락·중복·장애·`unscorable`을 별도 분모로 보고하며, `split: holdout` 사례는 결과를 보고 수정한 순간 `dev`로 이동한다. 절차·층 구조·독립성 조건은 [정본 D20](../../MOIRAI_ENGINE.md#역할-프롬프트와-개선-루프)이 소유한다. +새 `mechanismRevision`은 프롬프트 자산 revision·encoder/readout 버전·정책 코드 버전을 canonical JSON으로 묶어 만든 `sha256:<64자리 소문자 hex>`다. F2 메커니즘 owner가 내용을 구성한다. F1 파서는 형식과 입력 해시 결합을 확인하며, 기존 숫자 revision은 과거 기록의 원래 바이트·해시로 읽는다. 숫자 문자열이나 태그 없는 해시는 허용하지 않는다. 역할 자산의 역할 계약 층은 `ObjectiveProfile`에서 생성하므로 프로필 revision 변경은 자산 revision 변경이다. 자산 변경은 새 revision과 새 binding generation으로만 적용하고 진행 회차의 프롬프트를 바꾸지 않는다. 실험은 완전한 쌍만 품질 비교에 넣고 누락·중복·장애·`unscorable`을 별도 분모로 보고하며, `split: holdout` 사례는 결과를 보고 수정한 순간 `dev`로 이동한다. 절차·층 구조·독립성 조건은 [정본 D20](../../MOIRAI_ENGINE.md#역할-프롬프트와-개선-루프)이 소유한다. | 판단 모듈·고유 목표 | 입력과 계산 | 출력과 결과 확인 | | --- | --- | --- | @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 잘리지 않은 모듈 의견이다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. @@ -82,11 +82,11 @@ Forecast의 결과·비용·기한은 각각 `Claim { claimId, kind: observed | `moirai_prepare`는 세 의견과 근거에서 공통 `CanonicalOption` 집합을 제안한다. Host가 정규화한 뒤 각 모듈의 `evaluateOptions(snapshot, candidates)`를 호출한다. 각자는 자기 목표에 따른 후보별 추천과 이유를 채우고 클로토의 예측, 라케시스의 경험·단서 반응, 아트로포스의 의도·약속 충돌을 근거로 붙인다. 후보 key·snapshot·objectiveRef·mechanism revision이 같은 기존 결과는 재사용한다. -이미 계산 가능한 후보는 코드와 신경 조회로 평가한다. 새 후보의 예측·의미 해석이 부족하면 해당 모듈의 추가 LLM 호출과 비용을 기록한다. 최초 의견의 독립성과 이 후속 평가는 구분한다. 후속 평가에서도 다른 모듈의 결론은 입력하지 않는다. 각 `optionKey × moduleKind`에 유효 평가 또는 정책이 허용한 명시적 `unavailable` 사유가 있어야 한다. 비교할 수 없는 후보를 조용히 탈락시키거나 0점으로 취급하지 않는다. 필요한 평가가 없으면 회차를 보류한다. +이미 계산 가능한 후보는 코드와 신경 조회로 평가한다. 새 후보의 예측·의미 해석이 부족하면 해당 모듈의 추가 LLM 호출과 비용을 기록한다. 최초 의견의 독립성과 이 후속 평가는 구분한다. 후속 평가에서도 다른 모듈의 결론은 입력하지 않는다. 각 `optionKey × moduleKind`에 유효 평가 또는 정책이 허용한 명시적 `unavailable` 사유가 있어야 한다. 비교할 수 없는 후보를 조용히 탈락시키거나 0점으로 취급하지 않는다. 필요한 평가가 없거나 남은 후보의 평가가 `unavailable`이면 policy revision 2는 순위를 만들지 않고 보류한다. Host는 같은 요청의 예산 안에서 보완하며 저장된 Assessment를 덮어쓰지 않는다. 이미 기록한 회차를 재개하지 않고 필요하면 새 회차를 만든다. revision 1의 모듈 전체 제외 방식은 과거 재생에만 남긴다. 새 근거가 공통 세계 사실을 바꾸면 snapshot을 무효화하고 새 회차에서 셋 모두에게 공급한다. 새 후보·효과 범위가 추가되면 candidate revision을 올려 해당 평가를 완료한 뒤 선택한다. 평가 횟수·시간·모델·수치 계산 예산과 실패를 기록하며 예산 부족을 가짜 완전성으로 숨기지 않는다. -Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회차 deadline을 먼저 고정한다. 이 예산은 후보 revision이나 무효화 후 후속 회차에서도 같은 원래 요청/자율 활동 슬롯에 누적한다. `closeCandidateSet`이 후보 hash와 coverage를 확정한 뒤에는 새 후보를 같은 선택에 끼워 넣지 못한다. 확정 뒤 제안은 후속 회차에 남기고 실제 전제를 바꾸는 근거만 현재 회차를 무효화한다. 예산 소진 시 `deferred`로 끝내며 선택 RNG·outbox는 진행하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. +Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회차 deadline을 먼저 고정한다. 이 예산은 후보 revision이나 무효화 후 후속 회차에서도 같은 원래 요청/자율 활동 슬롯에 누적한다. `closeCandidateSet`이 후보 hash와 coverage를 확정한 뒤에는 새 후보를 같은 선택에 끼워 넣지 못한다. 확정 뒤 제안은 후속 회차에 남기고 실제 전제를 바꾸는 근거만 현재 회차를 무효화한다. 예산 소진 시 `deferred`로 끝내며 선택 RNG·outbox는 진행하지 않는다. 후보 또는 평가가 불완전한 행동 회차도 비실행 종료 기록을 저장할 수 있다. 이때 `SelectionSpec`·순위·양보·충돌 판정은 없고, `holdReason`에 종료 원인을 남긴다. 제공된 후보 근거와 현재성 검사는 그대로 수행한다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. `AssessmentSet`은 snapshotId·candidateSetHash·objectiveProfileRefs·모듈별 평가 해시·누락 사유를 묶는다. Host는 현재성·필수 조건으로 적격 후보를 확인한다. 모이라이의 종합 기능은 LLM 해석과 `ArbitrationPolicy`를 포함하며, 각자의 추천을 유지한 채 목표 충돌을 조정한다. 종합 LLM이나 Host가 선언된 정책 밖의 임의 우선순위를 적용하지 않는다. @@ -185,7 +185,7 @@ b_a = boundedReadout(q_a) # [-1, 1] 일반 대화는 원래 대화·완결된 내부 의견 원문·구조화된 평가·상태 참조를 Moirai에 전달한다. 기본 LLM 수명은 3판단+1종합이며 모듈 계산과 추가 조회 비용을 별도 기록한다. PR #10의 익명 영어 텍스트 비교는 QA 대조군으로 보존하되 제품 계약은 mechanism별 typed assessment로 전환한다. 상태 수치가 진실·지시·권한이 되지 않으며 특정 자연어 답변 확률도 보장하지 않는다. 실행 효과를 만드는 제안은 아래 행동 확정을 거친다. -명시적 행동은 `3판단 → moirai_prepare → 공통 후보 평가 → 모이라이 조정·선택 정책 → Host 선택 기록 → moirai_finalize` 순서다. 3+2는 최초 LLM 호출의 기본 골격이며 추가 평가까지 다섯 번에 끝난다고 보장하지 않는다. finalize는 prepare의 동일 원문·완결된 의견과 확정된 AssessmentSet·선택 receipt를 받는다. 추가 평가와 재시도·조회·취소·usage를 합산한다. PR #10 capture의 6회 차단선과 예전 QA 출력 제한은 제품 예산으로 복사하지 않는다. 제품 출력 원문을 자르거나 임의 출력 토큰 상한을 추가하지 않는다. +명시적 행동은 `3판단 → moirai_prepare → 공통 후보 평가 → 모이라이 조정·선택 정책 → Host 선택 기록 → moirai_finalize` 순서다. 3+2는 최초 LLM 호출의 기본 골격이며 추가 평가까지 다섯 번에 끝난다고 보장하지 않는다. finalize는 prepare의 동일 원문·완결된 의견과 확정된 AssessmentSet·선택 receipt를 받는다. 추가 평가와 재시도·조회·취소·usage를 합산한다. PR #10 capture의 6회 차단선과 예전 QA 출력 제한은 제품 예산으로 복사하지 않는다. D23에 따라 제품 프리셋에 역할별 생성 토큰 예산을 선언하고 실제 Codex/OpenCodex 전송에서 적용 여부를 검증한다. 생성 완료 뒤 자르기는 생성 시간·비용 제한을 대신하지 않는다. `buildDialogueResolution`은 새 종합문을 4,000, 이유문을 1,000 UTF-16 code unit으로 제한하고 선택적 `readoutTruncations`에 원래 길이·상한·원문 해시를 남긴다. 기존 필드가 없는 기록에는 이 필드를 추가하지 않는다. 사용자 원문·정정·권한과 구조화된 행동·근거는 이 문장 자르기 대상이 아니다. 생성 한도로 필수 구조가 불완전하면 보완하거나 보류하며 정상 판단으로 표시하지 않는다. ### 후보의 의미와 효과 범위 @@ -197,7 +197,7 @@ LIFE의 기회 제시·개인 선택과 `legacy | moirai` 전환은 [017 D09](01 Host는 AssessmentSet의 현재성·평가 완전성·전제조건을 검사한다. 권한·명시적 금지·강제 실행 조건은 후보의 적격성을 결정하고 약속의 우선순위 충돌은 근거를 가진 재계획 대상으로 처리한다. hard/soft 구분은 정책과 원래 지시가 정하며 LLM이 유리한 쪽으로 재분류하지 않는다. -`p0`의 단일 생성자는 모이라이 `ArbitrationPolicy` 안의 `BaselinePolicy`다. 클로토의 미래 성과, 라케시스의 비신경 욕구·명시적 선호, 아트로포스의 연속성 평가를 구분해 읽고, catalog별 조정 규칙으로 기준 분포를 만든다. 신경 편향은 아래 `b`에서만 정량 반영하며 라케시스의 나머지 평가를 누락하지 않는다. 입력 필드·출처·비교 단위·우선 조건·양보·동률·미확인 처리를 policy revision에 고정한다. 구체적인 가중치나 조정 방식은 후속 설계 사항이며 선언한 정책이 없는 catalog는 실행하지 않는다. +`p0`의 단일 생성자는 모이라이 `ArbitrationPolicy` 안의 `BaselinePolicy`다. 클로토의 미래 성과, 라케시스의 비신경 욕구·명시적 선호, 아트로포스의 연속성 평가를 구분해 읽고, catalog별 조정 규칙으로 기준 분포를 만든다. 신경 편향은 아래 `b`에서만 정량 반영하며 라케시스의 나머지 평가를 누락하지 않는다. 입력 필드·출처·비교 단위·우선 조건·양보·동률·미확인 처리를 policy revision에 고정한다. 개인 catalog의 구체적인 순서·ratio·λ·판단 불가 처리는 정본 D17·D23과 policy revision 2가 소유한다. 선언한 정책이 없는 catalog는 실행하지 않는다. 선택 전 `SelectionSpec`에 snapshot·AssessmentSet·objectiveProfileRefs·ResolutionRecord·policy revision·적격 후보 hash·`p0`·`b`·`λ`를 고정한다. 조정의 논리적 소유권은 모이라이에 있고, Host는 같은 입력과 정책에서 재현되는지 검증하고 정책의 선택 함수를 실행한다. 임의 LLM 확률이나 Host의 별도 가치 기준으로 대체하지 않는다. 선택 전 근거를 보존하고 사후 설명으로 덮지 않는다. @@ -323,13 +323,13 @@ checkpoint는 기존 설치/checkpoint owner가 조정한다. 대상 scope의 ## 구현 단위와 완료 증거 -후속 변경 순서·파일 후보·의존 관계는 [017의 F1–F4 지도](017_moirai_module_composition.md#후속-설계와-구현의-의존-순서) 하나로 관리한다. F1에서 원본 참조·채택·판단의 계약을 정하고, F2에서 World 없는 한 개인의 세 메커니즘·Senpi 대화·학습·결과·복구를 연결한다. F2는 대화 중 채택·약속 변경에 필요한 공통 후보·선택·finalize도 포함한다. F3는 이를 LIFE 행동 catalog로 확장하고 기회/행동을 분리하며, F4는 성장 투영·공개 범위·운영 전환이다. 개인정보·정정·중복 반영 방지는 F1/F2부터 적용한다. +후속 변경 순서·파일 후보·의존 관계는 [017의 F1–F4 지도](017_moirai_module_composition.md#후속-설계와-구현의-의존-순서) 하나로 관리한다. F1에서 원본 참조·채택·판단의 계약을 정하고, F2에서 World 없는 한 개인의 세 메커니즘·Codex 대화·학습·결과·복구를 연결한다. F2는 대화 중 채택·약속 변경에 필요한 공통 후보·선택·finalize도 포함한다. F3는 이를 LIFE 행동 catalog로 확장하고 기회/행동을 분리하며, F4는 성장 투영·공개 범위·운영 전환이다. 개인정보·정정·중복 반영 방지는 F1/F2부터 적용한다. -대조한 dev에는 PR #10의 Senpi 3+1 회차가 제품 통합돼 있지 않다. `session-app.ts`와 제안 `cognition/install.ts`가 Host 조정기·Senpi 역할 세션을 조립하고 `cognition/conversation.ts`는 `SessionPort`에 하나의 논리 대화 수명을 제공한다. 회차 ledger·채택·실행 권한은 SDK 어댑터 내부로 숨기지 않는다. 기존 [SessionEngine.kind](../../../packages/lina-runtime/src/session-engine.ts#L6)의 Codex 고정 타입·생성 소비자·잠근 의존성과 엔진 정책도 후속 전환 단위에서 갱신한다. 개발 작업 실행은 [정본 D21](../../MOIRAI_ENGINE.md#확정된-결정-목록)에 따라 `TaskManager`의 RPC 백엔드를 `omo app-server`로 교체하며, 프로바이더 계정·모델 목록·사용량은 같은 app-server의 `account/*`·`model/list`를 읽는다. Lina의 작업 ID·request digest·receipt·권한 정책은 백엔드 교체와 무관하게 유지하고, app-server의 thread/turn 식별자를 Lina 작업 ID로 대체하지 않는다. +F1의 공통 계약은 제품 회차 조정기에 아직 연결되지 않았다. `session-app.ts`와 제안 `cognition/install.ts`가 Host 조정기·Codex 역할 thread를 조립하고 `cognition/conversation.ts`가 `SessionPort`에 하나의 논리 대화 수명을 제공한다. 회차 ledger·채택·실행 권한은 어댑터 내부로 숨기지 않는다. `SessionEngine.kind`의 Codex 선택, TaskManager RPC와 OpenCodex 프로바이더 owner는 D22에 따라 유지한다. F2에서 역할 격리·도구 권한·출력 예산의 실제 전송과 재개를 검증한다. ObjectiveProfile·ResolutionRecord·SelectionSpec·DialogueJudgmentRef는 F1에서 의미·생성/직렬화/복원 계약을 정하고 F2의 판단 입력·후보 평가·종합·캐시·선택 기록·결과 소비자에 연결한다. 실제 도입 때 이전 schema와의 변환·누락 처리도 함께 검증한다. -내부 조언은 사용자 발송·직접 효과 실행 권한을 갖지 않고, 최종 응답만 DurableRuntime의 출처 확인·entry 저장·settlement 경계로 보낸다. 필요한 근거 조회는 Host의 허용된 조회 계약으로 수행한다. 원문·완결된 내부 의견·구조화된 평가·출력 보존을 실제 Senpi 제품 전송에서 검증한다. PR #10의 익명 문자열 schema를 변경하는 마이그레이션이며 예전 QA를 제품 적합성 증거로 대신하지 않는다. Senpi에서 관찰 가능한 session/run 식별자와 Host의 회차·source 증거를 대응시키며, Codex 전용 `nativeEpoch` 의미를 이름만 바꿔 재사용하지 않는다. 다른 Senpi/Moirai 통합이 먼저 병합되면 그 소유자를 확장하며 두 어댑터·회차 조정기를 만들지 않는다. QA runner에서만 성공한 결과는 F2 완료가 아니다. +내부 조언은 사용자 발송·직접 효과 실행 권한을 갖지 않고, 최종 응답만 DurableRuntime의 출처 확인·entry 저장·settlement 경계로 보낸다. 필요한 근거 조회는 Host의 허용된 조회 계약으로 수행한다. 필수 원문·정정·권한과 구조화된 판단이 실제 Codex 전송에 남는지 검사한다. 설명문의 출력 예산·잘림 표시도 전송과 저장에서 각각 확인한다. Codex thread/turn·nativeEpoch와 Lina round/request 식별자를 구분하고, 실제 재개·취소·늦은 응답 분리를 검증한다. 검증은 개발자가 선언한 정답 감정을 맞히는 시험으로 끝내지 않는다. diff --git a/docs/plans/platform/017_moirai_module_composition.md b/docs/plans/platform/017_moirai_module_composition.md index 4b12bb0..155f139 100644 --- a/docs/plans/platform/017_moirai_module_composition.md +++ b/docs/plans/platform/017_moirai_module_composition.md @@ -1,6 +1,6 @@ # 모이라이 엔진 구현 로드맵과 소스 재료 -상태: 2026-09-12. 정본 [MOIRAI_ENGINE](../../MOIRAI_ENGINE.md)의 결정을 현재 소스에 연결한다. 이 문서는 소스 재료 표, LIFE 전환, F1–F4 의존 순서, 확정된 결정의 남은 산출물, 검증 가설을 소유한다. 모듈 정의·조정 정책·행동 catalog·의도 schema·회로 프로필은 정본이, 타입·포트·저장·복구는 [016 계약](016_neural_preference_contract.md)이, 근거는 [015](015_neural_preference_engine_research.md)가, 채널·모델 운영은 [030](../context-engines/030_moirai_refactor_plan.md)이 소유한다. 아래 경로와 타입은 구현 후보이며 구현 완료나 통과한 테스트를 뜻하지 않는다. +상태: 2026-09-13. 정본 [MOIRAI_ENGINE](../../MOIRAI_ENGINE.md)의 결정을 현재 소스에 연결한다. 이 문서는 소스 재료 표, LIFE 전환, F1–F4 의존 순서, 확정된 결정의 남은 산출물, 검증 가설을 소유한다. 모듈 정의·조정 정책·행동 catalog·의도 schema·회로 프로필은 정본이, 타입·포트·저장·복구는 [016 계약](016_neural_preference_contract.md)이, 근거는 [015](015_neural_preference_engine_research.md)가, 채널·모델 운영은 [030](../context-engines/030_moirai_refactor_plan.md)이 소유한다. 아래 경로와 타입은 구현 후보이며 구현 완료나 통과한 테스트를 뜻하지 않는다. ## 현재 구현에서 확인한 재료 @@ -16,12 +16,12 @@ | [LIFE 욕구·목표](../../../packages/lina-core/src/world/autonomy-types.ts#L16), [사건 선택](../../../packages/lina-core/src/world/events.ts#L25) | 욕구 drift, 목표 우선순위·진행률, 성향·습관·반복 패널티를 반영한 사건/참여자 선택 | 욕구·목표 원본은 재사용. 기존 개인 선택 부분은 아래 LIFE 전환 계약으로 분리 | | [Ensemble 어댑터](../../../packages/lina-runtime/src/life/social/execution.ts#L104) | 규칙 기반 사회적 의향, 작성된 행동 그래프 탐색, 상대 반응·효과 계산 | 공통 사회 도메인 서비스. 라케시스는 의향 근거, 클로토는 허용된 결과 가정에 활용 | | [NativePersonaGrowth](../../../packages/lina-runtime/src/persona/native-growth.ts#L25), [BehaviorStore](../../../packages/lina-core/src/agents/behavior-store.ts#L75) | 유효한 기억을 성향·습관으로 해석하고 대화·LIFE에 투영 | 공통 성향 소유자. reflection/neural 생성자를 dimension별로 지정 | -| [TaskManager](../../../packages/lina-codex/src/tasks.ts#L139), [작업 도구](../../../packages/lina-runtime/src/tools/codex-tasks.ts#L25) | 지속되는 개발 작업, 지시·중단·인계·결과 통지 | 실행 소유자. catalog v1의 `task.*` effect owner. D21에 따라 RPC 백엔드를 Codex CLI에서 `omo app-server`로 교체하되 작업 ID·receipt·권한 계약은 유지. 프로세스 종료를 목표 달성으로 간주하지 않음 | +| [TaskManager](../../../packages/lina-codex/src/tasks.ts#L139), [작업 도구](../../../packages/lina-runtime/src/tools/codex-tasks.ts#L25) | 지속되는 개발 작업, 지시·중단·인계·결과 통지 | 실행 소유자. catalog v1의 `task.*` effect owner. D22에 따라 Codex app-server RPC를 유지하고 작업 ID·receipt·권한 계약은 유지. 프로세스 종료를 목표 달성으로 간주하지 않음 | | [이미지 작업](../../../packages/lina-runtime/src/images/jobs.ts#L39), [LIFE 게시](../../../packages/lina-runtime/src/life/publication.ts#L302) | 생성·편집·취소·복구, 게시·답글·아바타 연결 | 표현·실행 소유자. 무엇을 표현할지의 개인 판단은 모이라이에 요청 | -| [AgentFleet](../../../packages/lina-runtime/src/fleet/manager.ts#L39), [세션 조립](../../../packages/lina-runtime/src/session-app.ts#L173) | 개인별 세션·저장소, 문맥·기억·성장·도구 연결 | 공통 구성점. 모이라이 회차 조정기와 Senpi 역할 세션을 설치 | +| [AgentFleet](../../../packages/lina-runtime/src/fleet/manager.ts#L39), [세션 조립](../../../packages/lina-runtime/src/session-app.ts#L173) | 개인별 세션·저장소, 문맥·기억·성장·도구 연결 | 공통 구성점. 모이라이 회차 조정기와 Codex 역할 thread을 설치 | | [실행 통제](../../../packages/lina-runtime/src/execution.ts#L50), [DurableRuntime](../../../packages/lina-runtime/src/runtime.ts#L20) | 요청·응답 기록, 실행 권한·취소·복구 | 확정된 판단을 실제 효과로 넘기는 Host. 판단 저장과 효과 저장 책임 구분 | | [웹](../../../packages/lina-web/src/server.ts#L53), [Discord](../../../packages/lina-channels/src/discord-bridge.ts#L24) | 외부 입력·최종 응답 전달 | 채널. 내부 세 의견을 세 사용자 메시지로 전송하지 않음. Telegram은 현재 stub | -| [모델 서비스](../../../packages/lina-opencodex/src/services.ts#L323), [체크포인트](../../../packages/lina-runtime/src/checkpoint-cli.ts#L18) | 모델 라우팅과 전문 호출, 오프라인 상태 캡처·새 경로 복원 | 공통 인프라. D21에 따라 OpenCodex Hub의 카탈로그·역할 모델 선택은 Senpi native(`account/providerAccounts/*`, `model/list`)와 검증된 프리셋으로 대체하고 `lina-opencodex`는 폐기 대상. checkpoint는 엔진 저장소를 기존 설치/복구 책임 아래 편입 | +| [모델 서비스](../../../packages/lina-opencodex/src/services.ts#L323), [체크포인트](../../../packages/lina-runtime/src/checkpoint-cli.ts#L18) | 모델 라우팅과 전문 호출, 오프라인 상태 캡처·새 경로 복원 | 공통 인프라. D22에 따라 OpenCodex Hub의 카탈로그·계정 소유권과 `lina-opencodex` 어댑터를 유지하고 검증된 프리셋을 적용. checkpoint는 엔진 저장소를 기존 설치/복구 책임 아래 편입 | 현재 대화의 기본 경로는 `입력 → 개인 세션 → 문맥·페르소나·기억 조회 → LLM/도구 → 응답 정착 → 기억·성향 후처리`다. LIFE는 `사건/참여자 선택 → director → actor → 필요 시 target → Ensemble → reflection → 상태 확정`이다. 두 경로 모두 근거와 상태를 보존하지만, 세 관점이 공통 후보를 판단하는 제품 회차는 아직 없다. @@ -29,10 +29,10 @@ | 코드 기준 | 상태와 재사용 범위 | | --- | --- | -| [현재 MoiraiProbe](../../../packages/lina-codex/src/moirai-probe.ts#L67) | Codex QA 전용 3판단+종합·이력 대조. 제품 채널·도메인 포트는 연결되지 않음. 새 Senpi 조정기로 복제하지 않음 | -| [PR #10 Senpi](https://github.com/thisisjun786/lina/blob/5b22aee53f9f7c01cc508289099f662aed613140/scripts/qa/senpi-sdk/README.md) | 다른 브랜치의 SDK QA. `createAgentSession`·`ModelRuntime`·`SessionManager` 사용 예와 세션·취소·재개 검증. 역할 프롬프트는 이전 렌즈(라케시스=근거, 아트로포스=상황 선택)이므로 F2에서 정본의 목표로 재작성(D13) | +| [현재 MoiraiProbe](../../../packages/lina-codex/src/moirai-probe.ts#L67) | Codex QA 전용 3판단+종합·이력 대조. 제품 채널·도메인 포트는 연결되지 않음. QA 코드를 제품 조정기로 복제하지 않음 | +| [PR #10 Senpi](https://github.com/thisisjun786/lina/blob/5b22aee53f9f7c01cc508289099f662aed613140/scripts/qa/senpi-sdk/README.md) | 병합된 비교용 SDK QA. `createAgentSession`·`ModelRuntime`·`SessionManager` 사용 예와 세션·취소·재개 검증. 역할 프롬프트는 이전 렌즈(라케시스=근거, 아트로포스=상황 선택)이므로 F2에서 정본의 목표로 재작성(D13) | | [PR #8 채택 커널](https://github.com/thisisjun786/lina/blob/ab9f1f073eca80ecef8dda0b0f7d338f4d6cb35c/scripts/qa/adoption-kernel/types.ts#L24) | PR은 미병합 종료. `Purpose`·`Adoption(understanding \| plan \| intention)`·`Judgment(method, expectation)`·`ToolReceipt`·`ProposalAction(answer \| adopt \| tool \| defer \| noop)`의 의미를 정본의 `IntentionRecord`와 catalog v1로 이전. Frame·DB·독립 평가 통과는 전제하지 않음 | -| [PR #11 프롬프트 방법론](https://github.com/thisisjun786/lina/blob/25346f15287a96d7e95e8c3e07c51fff1899f66f/docs/plans/platform/014_model_tuning_methodology_research.md) | 미병합 문서. 작성 순서·A/B/C 진단·비교 자동화·누출 점검을 정본 D20이 채택. 그 문서의 역할 표(라케시스=분석가, 아트로포스=결정자)와 "실행 엔진은 Codex" 문장, 030의 옛 anchor 링크는 병합 시 정본에 맞춰 갱신 필요 | +| [PR #11 프롬프트 방법론](https://github.com/thisisjun786/lina/blob/25346f15287a96d7e95e8c3e07c51fff1899f66f/docs/plans/platform/014_model_tuning_methodology_research.md) | 병합된 방법론. 작성 순서·A/B/C 진단·비교 자동화·누출 점검을 정본 D20이 채택. 그 문서의 역할 표(라케시스=분석가, 아트로포스=결정자)와 "실행 엔진은 Codex" 문장, 030의 옛 anchor 링크는 병합 시 정본에 맞춰 갱신 필요 | | PR #8 이후 로컬 검증 후보 `0cdfd43` | 프로세스 복구·실패 결과 검증을 추가한 별도 후보. 제품 의존성으로 채택하지 않고 후속 설계 때 공개 가능한 리비전과 결과를 다시 확인 | | [PR #5 UI](https://github.com/thisisjun786/lina/pull/5) | 별도 UI·공통 client·Electron 구현. 인지 원본을 UI 패키지로 옮기지 않음. 엔진 완성 뒤 고도화 | @@ -62,14 +62,16 @@ Ensemble의 행동 그래프가 개인이 통제하는 서로 다른 행동을 | 단계 | 변경 후보와 전후 차이 | 해당 단계에서 확인할 결과 | | --- | --- | --- | -| F1 공통 계약·소유권 | NEW core `agents/judgment.ts`(ObjectiveProfile·Assessment·ResolutionRecord·SelectionSpec·IntentionRecord), `judgment-store.ts`; MODIFY core `context/types.ts`의 읽기 투영 계약·`agents/behavior-types.ts`·`PersonaSchema`; catalog `personal.v1` schema와 `ArbitrationPolicy` revision 1 정의 | 원문·이해·목적·계획·의도와 모듈별 ObjectiveProfile을 구분하고 세 판단에 같은 필수 입력 전달. 지시 revision과 WorkingState revision을 혼동하지 않음. 정책·catalog·schema의 직렬화/복원과 이전 값 처리 | -| F2 세 메커니즘·Senpi·회로 | NEW runtime `cognition/install.ts`, `conversation.ts`, `prospect.ts`, `value.ts`, `continuity.ts`, `option-assessments.ts`, `arbitration-policy.ts`, `senpi/session.ts`, `neural-preference/{port,client,store}.ts`, `prompts/{common,clotho,lachesis,atropos,moirai}.ts`를 `PromptAsset` 층 구조로 작성하고 구조·전송 검사; NEW `lina-codex`의 app-server 클라이언트에 `omo app-server` 전송(unix/ws)·`dynamicTools`·approval 실전송 검사(D21); MODIFY `session-app.ts`, `session-engine.ts`, `sdk-port.ts`, `runtime.ts`, core `store.ts`; 별도 Python 계산 artifact와 회로 프로필 v1 추출 | World 없는 한 개인에서 세 판단+종합, catalog v1 행동 확정, MaleCNS 관측·조회·학습, 의도 유지, 실제 결과 환류, 재시작 연결. 회로 프로필 v1의 `qualified` 검사 통과. QA runner만 성공한 상태로 완료 처리하지 않음 | +| F1 공통 계약·소유권 | core `agents/judgment*.ts`, `context/read-projection.ts`, `agents/behavior-types.ts`·`PersonaSchema`; catalog `personal.v1`과 policy revision 2. PR #14의 계약에 D23 보완 | 원본·지시·WorkingState revision 분리, 목표 참조·정책·대화/행동 기록의 복원, 출력 잘림 표시, 비실행 deferred, 과거 policy revision 1 재생. 공통 계약 구현은 제품 연결 완료를 뜻하지 않음 | +| F2 세 메커니즘·Codex·회로 | NEW runtime `cognition/install.ts`, `conversation.ts`, `prospect.ts`, `value.ts`, `continuity.ts`, `option-assessments.ts`, `arbitration-policy.ts`, `codex/session.ts`, `neural-preference/{port,client,store}.ts`, `prompts/{common,clotho,lachesis,atropos,moirai}.ts`; MODIFY 기존 `lina-codex` RPC·SessionPort 연결과 `session-app.ts`, `runtime.ts`, core `store.ts`; Python 계산 artifact와 회로 프로필 v1 | World 없는 한 개인의 세 판단+종합, catalog 행동 확정, MaleCNS 관측·조회·학습, 의도 유지·결과 환류·재시작. Codex 역할 격리·dynamicTools·approval·생성 토큰 예산·잘림·부분 평가 보완의 실제 제품 검사와 회로 qualified 검사 | | F3 World/LIFE 행동 확장 | NEW 도메인 투영 포트(`projectConsequences`); EXTEND F2의 공통 후보·선택 정책을 LIFE catalog에 적용; MODIFY core `world/events.ts`, pack/step types·codecs·`autonomy-persistence.ts`, runtime `life/director.ts`, 사회 계산/실행 포트·게시/이미지 연결 | 사건 기회와 개인 선택 분리, legacy 재현, LIFE catalog의 선언된 조정 정책, 세 후보 평가·단일 선택·상대의 독립 결정·실제 owner 효과의 일치 | -| F4 성장·공개 범위·운영 | MODIFY 기존 BehaviorStore/Persona 투영, Fleet·후처리·모델 프리셋·scheduler·설치/checkpoint 소비자, AGENTS/README의 엔진 계약; NEW `scripts/qa/moirai-evals/` 프롬프트 비교 harness(Senpi evals 패턴 재사용)와 고정 검증군; MODIFY `lina-opencodex` 소비자(`models/*`, 설정 HTTP/UI)를 Senpi native 프로바이더 계정·프리셋으로 전환하고 Codex CLI·OpenCodex 의존성 제거, 기존 세션 binding·`models.sqlite`·작업 이력의 보존·전환 필요 표시(D21) | 성향 중복 가산과 비밀 scope 유출 없음, 8명 비동기·실제 RAM/VRAM/지연·다중 저장소 복원·Python·app-server 프로세스 장애 시험, 첫 프롬프트 개선 루프를 D20 절차로 완주한 기록, Codex/OpenCodex 없는 설치본에서 대화·작업·프로바이더 확인이 동작 | +| F4 성장·공개 범위·운영 | MODIFY BehaviorStore/Persona 투영, Fleet·후처리·모델 프리셋·scheduler·설치/checkpoint 소비자, AGENTS/README; NEW `scripts/qa/moirai-evals/` Codex 비교 harness와 고정 검증군; 기존 OpenCodex 모델 소비자에 검증 프리셋 경계 적용 | 성향 중복 가산·비밀 scope 유출 없음, 8명 비동기·RAM/VRAM/지연·다중 저장소 복원·Python/Codex 프로세스 장애 검사, D20 첫 개선 루프 완주. Codex/OpenCodex를 유지한 설치본에서 대화·작업·프로바이더 연결 검증 | -F1에서 의미·정체성·공개 범위와 중복 반영 방지를 먼저 정한다. F2에서 별도 `agentState`로 신경 반응을 제공하더라도 기존 성향과 겹치는 dimension은 제외하거나 생성자 전환을 함께 수행한다. F4까지 근거·격리 검사를 미루지 않는다. 프리셋의 역할·티어·입력/출력 계약도 F2의 실제 Senpi 전송에 적용하고 F4에서 운영 전환을 검증한다. 030의 이전 R 번호는 이 지도에 대응시키며 별도 개발 루프로 실행하지 않는다. +F1에서 의미·정체성·공개 범위와 중복 반영 방지를 먼저 정한다. F2에서 별도 `agentState`로 신경 반응을 제공하더라도 기존 성향과 겹치는 dimension은 제외하거나 생성자 전환을 함께 수행한다. F4까지 근거·격리 검사를 미루지 않는다. 프리셋의 역할·티어·입력/출력 계약도 F2의 실제 Codex 전송에 적용하고 F4에서 운영 전환을 검증한다. 030의 이전 R 번호는 이 지도에 대응시키며 별도 개발 루프로 실행하지 않는다. -새 필드의 후속 구현은 생성 → 직렬화 → 역직렬화/이전 값 → 모든 소비자를 함께 명시한다. `ObjectiveProfile`·`ResolutionRecord`·`SelectionSpec`·`DialogueJudgmentRef`·`IntentionRecord`는 생성·판단 입력·캐시 key·ledger·복원·종합 소비자를 함께 연결한다. `decisionMode`는 pack/설정 입력·builder, 저장 schema/step digest, codec·legacy decoder, 사건 선택·director·재생·UI 상태 소비자까지 포함한다. `NeuralProjectionRef`는 생성·DB/manifest·복원·Persona/LIFE 소비자 전부를, Senpi 식별자는 생성·binding 저장·재개·취소·usage 소비자를 대조해야 한다. +새 필드의 후속 구현은 생성 → 직렬화 → 역직렬화/이전 값 → 모든 소비자를 함께 명시한다. `ObjectiveProfile`·`ResolutionRecord`·`SelectionSpec`·`DialogueJudgmentRef`·`IntentionRecord`는 생성·판단 입력·캐시 key·ledger·복원·종합 소비자를 함께 연결한다. `decisionMode`는 pack/설정 입력·builder, 저장 schema/step digest, codec·legacy decoder, 사건 선택·director·재생·UI 상태 소비자까지 포함한다. `NeuralProjectionRef`는 생성·DB/manifest·복원·Persona/LIFE 소비자 전부를, Codex thread/turn 식별자는 생성·binding 저장·재개·취소·usage 소비자를 대조해야 한다. + +F1의 `Purpose`·`Understanding`·`Plan` 의미와 typed Forecast/Claim, `NeuralProjectionRef`의 회로 상태·학습 revision 결합은 아직 부분 계약이다. 원래 기능을 삭제하지 않으며 F2 메커니즘을 연결하기 전에 생성·저장·복원·소비자를 완성한다. AgentStore의 PersonaSchema 권위와 기존 데이터 이전은 아래 F4 범위를 따른다. ## 확정된 결정과 남은 산출물 @@ -82,12 +84,12 @@ F1에서 의미·정체성·공개 범위와 중복 반영 방지를 먼저 정 | 의도의 최소 schema | D16 `IntentionRecord` v1 | F1: 직렬화·전이 검증. F2: 약속·자율 목표의 충돌·완료·철회 시나리오 | | 성향 축의 소유권 | D18 AgentStore `PersonaSchema` | F1: schema 정의. F4: 기존 Behavior receipt·projection의 데이터 이전 | | 회로/의미 인터페이스 | D14 회로 프로필 v1 | F2: body 목록·부호·상수·해시 추출, encoder/readout 구현, `qualified` 검사 | -| 목표별 판단·종합 정책 | D17 `personal.v1` 조정 정책 | F1: policy revision 1 코드화. F2: ResolutionRecord/SelectionSpec 재현 검증. 효용 비교는 아래 가설 | +| 목표별 판단·종합 정책 | D17 `personal.v1` 조정 정책 | F1: policy revision 2와 revision 1 과거 재생. F2: ResolutionRecord/SelectionSpec 재현 검증. 효용 비교는 아래 가설 | | 제품 세션·저장 경계 | D19 저장소 셋과 Python 계산기 하나 | F2: JudgmentStore·NeuralPreferenceStore·outbox/inbox·checkpoint manifest 편입 | | 프롬프트 작성·개선 | D20 프롬프트 자산·다섯 층·8단계 루프 | F2: 자산 revision·층 해시·wire 검사. F4: 비교 harness·독립 judge·고정 검증군·첫 루프 완주 | -| 개발 작업 실행 엔진·프로바이더 관리 | D21 OMO native app-server + Senpi native 계정·모델 | F2: `lina-codex` 클라이언트의 `omo app-server` 전송·`dynamicTools`·approval 실검사. F4: `lina-opencodex` 소비자 전환, Codex CLI·OpenCodex 제거, 기존 데이터 보존·전환 표시, 폐기 | +| 개발 작업 실행 엔진·프로바이더 관리 | D22 Codex app-server + OpenCodex | F2: 기존 어댑터의 역할·도구·승인·예산·재개 실검사. F4: 기존 데이터 보존과 검증 프리셋·새 엔진 저장소의 설치/복구 검사 | -다음 작업은 정본 → 016 → 이 문서 → 030 순서로 읽고, 소스/PR 리비전을 갱신한 뒤 F1부터 시작한다. 판단을 바꿀 때는 D 번호와 이유를 남기고 연관 계약도 함께 갱신한다. 실제 구현 착수는 별도 요청에서 결정한다. +다음 작업은 정본 → 016 → 이 문서 → 030 순서로 읽고, PR #14와 D22·D23의 F1 보완을 확인한 뒤 F2 계약과 연결을 구현한다. 판단을 바꿀 때는 D 번호와 이유를 남기고 연관 계약도 함께 갱신한다. F2 제품 구현·실모델 검증은 해당 작업의 범위에서 수행한다. ## 검증할 가설과 반례 diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 36285bd..f2757e0 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -97,3 +97,4 @@ export { behaviorFingerprint } from "./behavior-validation.ts"; export * from "./judgment-candidates.ts"; export * from "./judgment-dialogue.ts"; +export { buildAssessment } from "./judgment-output.ts"; diff --git a/packages/lina-core/src/agents/judgment-dialogue.ts b/packages/lina-core/src/agents/judgment-dialogue.ts index 50e1a03..359e0cd 100644 --- a/packages/lina-core/src/agents/judgment-dialogue.ts +++ b/packages/lina-core/src/agents/judgment-dialogue.ts @@ -3,9 +3,11 @@ import { type Assessment, type DialogueSourceRef, type JudgmentSnapshotRef, + MAX_READOUT_TEXT, MODULE_KINDS, type ModuleKind, type ObjectiveProfileRef, + type ReadoutTruncation, type ResolutionRecord, type RoundStatus, SITUATIONS, @@ -17,10 +19,12 @@ import { parseAssessment, parseJudgmentSnapshotRef, parseObjectiveProfileRef, + parseReadoutTruncation, parseResolutionRecord, + prepareReadout, snapshotDigest, } from "./judgment-validation.ts"; -import { boundedId, boundedText } from "./validation.ts"; +import { boundedId, boundedText, MAX_TEXT } from "./validation.ts"; type DialogueProvenance = DialogueSourceRef & { roundId: string; @@ -45,6 +49,10 @@ export type DialogueResolutionRecord = DialogueProvenance & { rationale: string; status: Exclude; holdReason: string | null; + /** Absent in legacy records; only fresh output preparation adds this. */ + readoutTruncations?: Partial< + Record<"synthesis" | "rationale", ReadoutTruncation> + >; }; export type StoredResolutionRecord = | ResolutionRecord @@ -156,6 +164,10 @@ function provenance(row: Record): DialogueProvenance { export function parseDialogueResolutionRecord( value: unknown, ): DialogueResolutionRecord { + const hasTruncations = + value !== null && + typeof value === "object" && + Object.hasOwn(value, "readoutTruncations"); const row = fields( value, [ @@ -171,6 +183,7 @@ export function parseDialogueResolutionRecord( "rationale", "status", "holdReason", + ...(hasTruncations ? ["readoutTruncations"] : []), ], "dialogue resolution", 2, @@ -190,7 +203,11 @@ export function parseDialogueResolutionRecord( ), conflicts: reasons(row["conflicts"]), concessions: reasons(row["concessions"]), - synthesis: boundedText(row["synthesis"], "dialogue synthesis"), + synthesis: boundedText( + row["synthesis"], + "dialogue synthesis", + MAX_READOUT_TEXT, + ), rationale: boundedText(row["rationale"], "dialogue rationale"), status: member( row["status"], @@ -202,6 +219,27 @@ export function parseDialogueResolutionRecord( ? null : boundedText(row["holdReason"], "hold reason"), }; + if (hasTruncations) { + const raw = row["readoutTruncations"]; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) + throw Error("invalid dialogue truncations"); + const keys = Object.keys(raw); + if ( + !keys.length || + keys.some((key) => key !== "synthesis" && key !== "rationale") + ) + throw Error("invalid dialogue truncations"); + const metadata = fields(raw, keys, "dialogue truncations"); + result.readoutTruncations = {}; + for (const field of ["synthesis", "rationale"] as const) { + if (Object.hasOwn(metadata, field)) + result.readoutTruncations[field] = parseReadoutTruncation( + metadata[field], + result[field], + field === "synthesis" ? MAX_READOUT_TEXT : MAX_TEXT, + ); + } + } if ((result.status !== "resolved") !== (result.holdReason !== null)) throw Error("unresolved status requires hold reason"); const incomplete = MODULE_KINDS.some( @@ -320,8 +358,24 @@ export function buildDialogueResolution( const found = assessments.find((a) => a.moduleKind === module); return found ? judgmentDigest(found) : null; }; + // Existing metadata belongs to already prepared output, not a fresh generation. + if (Object.hasOwn(synthesis, "readoutTruncations")) + throw Error("fresh dialogue must not supply truncation metadata"); + const prose = prepareReadout(synthesis.synthesis, "dialogue synthesis"); + const rationale = prepareReadout( + synthesis.rationale, + "dialogue rationale", + MAX_TEXT, + ); + const readoutTruncations = { + ...(prose.truncation ? { synthesis: prose.truncation } : {}), + ...(rationale.truncation ? { rationale: rationale.truncation } : {}), + }; const result = parseDialogueResolutionRecord({ ...synthesis, + synthesis: prose.text, + rationale: rationale.text, + ...(Object.keys(readoutTruncations).length ? { readoutTruncations } : {}), schemaVersion: 2, mode: "dialogue", roundId: snapshot.roundId, diff --git a/packages/lina-core/src/agents/judgment-output.ts b/packages/lina-core/src/agents/judgment-output.ts new file mode 100644 index 0000000..3f1b9b0 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-output.ts @@ -0,0 +1,24 @@ +import type { Assessment } from "./judgment.ts"; +import { + assessmentInputDigest, + parseAssessment, + prepareReadout, +} from "./judgment-validation.ts"; + +/** Prepare a new model judgment before its immutable record digest is created. */ +export function buildAssessment( + input: Omit, +): Assessment { + const readout = prepareReadout(input.completeText, "complete text"); + const parsed = parseAssessment({ + ...input, + inputDigest: assessmentInputDigest(input), + completeText: readout.text, + }); + if (readout.truncation) + parsed.diagnostics = { + ...parsed.diagnostics, + readoutTruncation: readout.truncation, + }; + return parsed; +} diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index b565001..2aaf261 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -51,6 +51,23 @@ export const PERSONAL_POLICY_V1: ArbitrationPolicy = Object.freeze({ stanceOrder: Object.freeze(["prefer", "accept", "oppose"] as const), unavailableReasons: Object.freeze([...ASSESSMENT_UNAVAILABLE_REASONS]), }); +/** Revision one remains immutable for historical replay. */ +export const PERSONAL_POLICY_V2: ArbitrationPolicy = Object.freeze({ + ...PERSONAL_POLICY_V1, + revision: 2, +}); +export const PERSONAL_POLICY_CURRENT = PERSONAL_POLICY_V2; + +export function personalPolicyFor( + policyId: string, + revision: number, +): ArbitrationPolicy { + const policy = [PERSONAL_POLICY_V1, PERSONAL_POLICY_V2].find( + (p) => p.policyId === policyId && p.revision === revision, + ); + if (!policy) throw Error("unsupported personal policy declaration"); + return policy; +} export type HostEligibility = Array<{ optionKey: OptionKey; eligible: boolean; @@ -127,7 +144,12 @@ export function resolvePersonalRound(input: { policy.revision !== snapshot.policyRevision ) throw Error("policy snapshot mismatch"); - if (!isDeepStrictEqual(policy, PERSONAL_POLICY_V1)) + if ( + !isDeepStrictEqual( + policy, + personalPolicyFor(policy.policyId, policy.revision), + ) + ) throw Error("unsupported personal policy declaration"); for (const assessment of set.assessments) { const objective = snapshot.objectiveProfileRefs[assessment.moduleKind]; @@ -273,8 +295,20 @@ export function resolvePersonalRound(input: { // 9: No candidate means deferred, not an empty ordering or uniform fallback. if (remaining.length === 0) return finishWithoutSpec("deferred", "no eligible candidate"); + if (policy.revision >= 2) { + for (const candidate of remaining) { + const module = order.find( + (m) => candidate.opinions[m].stance === "unavailable", + ); + if (module) + return finishWithoutSpec( + "held", + `unavailable opinion ${module} for ${candidate.option.optionKey}`, + ); + } + } - // 5: Drop an unavailable module for the ENTIRE round, never per comparison. + // Legacy v1 drops unavailable modules. V2 reaches here only with full coverage. const orderingModules = order.filter((m) => remaining.every((c) => c.opinions[m].stance !== "unavailable"), ); diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index bb81407..2616fde 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -16,11 +16,14 @@ import { type JsonObject, type JsonValue, type JudgmentSnapshotRef, + MAX_READOUT_TEXT, + type MechanismRevision, MODULE_KINDS, type ModuleKind, type ObjectiveProfile, type ObjectiveProfileRef, type OptionAssessment, + type ReadoutTruncation, type ResolutionRecord, SEVERITIES, type SelectionSpec, @@ -196,7 +199,7 @@ export function judgmentDigest(value: unknown): string { export function assessmentInputDigest(input: { snapshotDigest: string; objectiveRef: ObjectiveProfileRef; - mechanismRevision: number; + mechanismRevision: MechanismRevision; }): string { return judgmentDigest({ snapshotDigest: input.snapshotDigest, @@ -204,6 +207,66 @@ export function assessmentInputDigest(input: { mechanismRevision: input.mechanismRevision, }); } + +function mechanismRevision(value: unknown): MechanismRevision { + if (typeof value === "string") { + if (!/^sha256:[0-9a-f]{64}$/.test(value)) + throw Error("invalid mechanism revision"); + return value as `sha256:${string}`; + } + return revision(value, "mechanism revision"); +} + +/** Only fresh output preparation clips text; persisted record parsers never do. */ +export function prepareReadout( + value: string, + label: string, + limit = MAX_READOUT_TEXT, +): { text: string; truncation: ReadoutTruncation | null } { + boundedText(value, label, Number.MAX_SAFE_INTEGER); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_READOUT_TEXT) + throw Error("invalid readout limit"); + if (value.length <= limit) return { text: value, truncation: null }; + let end = limit; + const last = value.charCodeAt(end - 1), + next = value.charCodeAt(end); + if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) + end--; + return { + text: boundedText(value.slice(0, end), label, limit), + truncation: { + originalLength: value.length, + limit, + sourceDigest: createHash("sha256").update(value).digest("hex"), + }, + }; +} + +export function parseReadoutTruncation( + value: unknown, + text: string, + limit: number, +): ReadoutTruncation { + const row = fields( + value, + ["originalLength", "limit", "sourceDigest"], + "readout truncation", + ); + const originalLength = revision( + row["originalLength"], + "original readout length", + ); + const sourceDigest = boundedId(row["sourceDigest"], "readout source digest"); + if ( + row["limit"] !== limit || + originalLength <= limit || + text.length < limit - 1 || + text.length > limit || + !/^[a-f0-9]{64}$/.test(sourceDigest) + ) + throw Error("invalid readout truncation"); + return { originalLength, limit, sourceDigest }; +} export function snapshotDigest(ref: JudgmentSnapshotRef): string { return judgmentDigest(ref); } @@ -515,8 +578,12 @@ export function parseAssessment(value: unknown): Assessment { snapshotDigest: boundedId(row["snapshotDigest"], "snapshot digest"), inputDigest: boundedId(row["inputDigest"], "input digest"), objectiveRef: objectiveProfileRef(row["objectiveRef"]), - mechanismRevision: revision(row["mechanismRevision"], "mechanism revision"), - completeText: boundedText(row["completeText"], "complete text"), + mechanismRevision: mechanismRevision(row["mechanismRevision"]), + completeText: boundedText( + row["completeText"], + "complete text", + MAX_READOUT_TEXT, + ), evidenceRefs: strings(row["evidenceRefs"], "evidence refs"), proposedOptionKeys: strings( row["proposedOptionKeys"], diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts index 39ccb9c..2626352 100644 --- a/packages/lina-core/src/agents/judgment.ts +++ b/packages/lina-core/src/agents/judgment.ts @@ -116,6 +116,15 @@ export type JsonValue = | JsonValue[] | JsonObject; export type JsonObject = { [key: string]: JsonValue }; +/** Numeric revisions are legacy; new mechanism owners use a canonical content hash. */ +export type MechanismRevision = number | `sha256:${string}`; +/** Storage/context bound, not a provider token budget. */ +export const MAX_READOUT_TEXT = 4000; +export type ReadoutTruncation = { + originalLength: number; + limit: number; + sourceDigest: string; +}; export type AssessmentDetail = { kind: "forecasts" | "values" | "continuity"; body: JsonObject; @@ -126,7 +135,7 @@ export type Assessment = { snapshotDigest: string; inputDigest: string; objectiveRef: ObjectiveProfileRef; - mechanismRevision: number; + mechanismRevision: MechanismRevision; completeText: string; evidenceRefs: string[]; proposedOptionKeys: OptionKey[]; diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts new file mode 100644 index 0000000..74d2729 --- /dev/null +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -0,0 +1,122 @@ +import { afterEach, expect, test } from "bun:test"; +import * as api from "../src/agents/index.ts"; +import { policyEvidenceFixture } from "./judgment-policy-evidence-fixture.ts"; + +const fixtures: ReturnType[] = []; +afterEach(() => { + for (const item of fixtures.splice(0)) item.fixture.close(); +}); +function setup() { + const item = policyEvidenceFixture(); + fixtures.push(item); + return item; +} +function withRevision( + input: ReturnType["input"], + revision: number, +) { + const { evidence: _evidence, ...withoutEvidence } = input; + const snapshot = { ...input.snapshot, policyRevision: revision }; + const digest = api.snapshotDigest(snapshot); + const assessments = input.set.assessments.map((a) => { + const next = { ...a, snapshotDigest: digest }; + return { ...next, inputDigest: api.assessmentInputDigest(next) }; + }); + return { + ...withoutEvidence, + snapshot, + set: { ...input.set, snapshotDigest: digest, assessments }, + }; +} + +test("current policy holds an unavailable opinion without rewriting revision one", () => { + const { input } = setup(); + const opinion = input.set.assessments[1]?.objectiveAssessments[0]; + if (!opinion) throw Error("missing fixture opinion"); + opinion.stance = "unavailable"; + opinion.unavailableReason = "insufficient_evidence"; + const old = api.resolvePersonalRound(input); + expect(old.resolution.status).toBe("resolved"); + const next = api.resolvePersonalRound({ + ...withRevision(input, 2), + policy: api.PERSONAL_POLICY_CURRENT, + }); + expect(next.resolution.status).toBe("held"); + expect(next.spec).toBeNull(); + expect(next.resolution.ranking).toEqual([]); + expect(next.resolution.conceded).toEqual([]); + expect(next.resolution.abstentions).toHaveLength(1); + expect(api.resolvePersonalRound(input)).toEqual(old); + expect(() => api.personalPolicyFor("personal.v1", 3)).toThrow(); + expect(() => api.personalPolicyFor("invented", 2)).toThrow(); +}); + +test("mechanism content hashes survive persistence and distinguish inputs", () => { + const { input, store, path, fixture } = setup(); + const legacy = input.set.assessments[0]; + if (!legacy) throw Error("missing fixture assessment"); + const before = JSON.stringify(api.parseAssessment(legacy)); + const mechanismRevision = `sha256:${"a".repeat(64)}` as const; + const next = { ...legacy, mechanismRevision }; + const parsed = api.parseAssessment({ + ...next, + inputDigest: api.assessmentInputDigest(next), + }); + expect(parsed.mechanismRevision).toBe(mechanismRevision); + expect( + api.assessmentInputDigest({ + ...next, + mechanismRevision: `sha256:${"b".repeat(64)}`, + }), + ).not.toBe(parsed.inputDigest); + store.putAssessment(parsed); + for (const other of input.set.assessments.slice(1)) + store.putAssessment(other); + const reopened = fixture.keep(new api.JudgmentStore(path)); + expect( + reopened.assessmentSet(input.snapshot.roundId).assessments, + ).toContainEqual(parsed); + expect(JSON.stringify(api.parseAssessment(legacy))).toBe(before); + for (const bad of [ + "1", + "a".repeat(64), + `sha256:${"A".repeat(64)}`, + "sha256:abc", + ]) { + expect(() => + api.parseAssessment({ ...parsed, mechanismRevision: bad }), + ).toThrow("invalid mechanism revision"); + } +}); + +test("fresh readout clips prose with provenance and leaves structured fields intact", () => { + const { input, store } = setup(); + const source = input.set.assessments[0]; + if (!source) throw Error("missing fixture assessment"); + const { inputDigest: _digest, ...raw } = source; + const text = `${"a".repeat(3999)}🧠tail`; + const prepared = api.buildAssessment({ ...raw, completeText: text }); + expect(prepared.completeText).toBe("a".repeat(3999)); + expect(prepared.diagnostics["readoutTruncation"]).toMatchObject({ + originalLength: 4005, + limit: 4000, + }); + expect(prepared.objectiveAssessments).toEqual(source.objectiveAssessments); + expect(prepared.inputDigest).toBe(source.inputDigest); + store.putAssessment(prepared); + for (const other of input.set.assessments.slice(1)) + store.putAssessment(other); + expect( + store.assessmentSet(input.snapshot.roundId).assessments, + ).toContainEqual(prepared); + expect(() => api.parseAssessment({ ...source, completeText: text })).toThrow( + "invalid complete text", + ); + expect(api.buildAssessment(raw)).toEqual(source); + expect(() => + api.buildAssessment({ ...raw, completeText: `${"a".repeat(5000)}\0` }), + ).toThrow(); + expect(() => + api.buildAssessment({ ...raw, recommendedOptionKeys: ["forged"] }), + ).toThrow(); +}); diff --git a/packages/lina-core/test/judgment-dialogue-contract.test.ts b/packages/lina-core/test/judgment-dialogue-contract.test.ts index 19c3def..8119da4 100644 --- a/packages/lina-core/test/judgment-dialogue-contract.test.ts +++ b/packages/lina-core/test/judgment-dialogue-contract.test.ts @@ -130,6 +130,49 @@ function db() { return fixture.keep(new DatabaseSync(path)); } +test("fresh dialogue bounds readouts before hashing and retains clipping provenance on reopen", () => { + seed(); + const original = record(); + const long = { + ...original, + synthesis: "s".repeat(5000), + rationale: "r".repeat(1400), + }; + expect(() => api.parseDialogueResolutionRecord(long)).toThrow(); + const prepared = api.buildDialogueResolution({ + ...long, + snapshot, + assessments, + }); + expect(prepared.synthesis).toBe("s".repeat(4000)); + expect(prepared.rationale).toBe("r".repeat(1000)); + expect(prepared.readoutTruncations).toMatchObject({ + synthesis: { originalLength: 5000, limit: 4000 }, + rationale: { originalLength: 1400, limit: 1000 }, + }); + expect(prepared.assessmentDigests).toEqual(original.assessmentDigests); + store.recordResolution(snapshot.roundId, prepared, null); + const before = store.dialogueJudgmentRef(snapshot.roundId); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(prepared); + expect(store.dialogueJudgmentRef(snapshot.roundId)).toEqual(before); + expect( + api.buildDialogueResolution({ ...original, snapshot, assessments }), + ).toEqual(original); + expect(() => + api.parseDialogueResolutionRecord({ + ...prepared, + readoutTruncations: { + synthesis: { + originalLength: 1, + limit: 4000, + sourceDigest: "a".repeat(64), + }, + }, + }), + ).toThrow(); +}); + for (const [field, value] of [ ["requestDigest", "0".repeat(64)], ["sourceDigest", "1".repeat(64)], From cc1dbc04068c93cd9965cbb85844b165db8ed369 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:50:26 +0900 Subject: [PATCH 02/12] fix(core): persist incomplete judgment budget receipts --- .../016_neural_preference_contract.md | 4 +- .../platform/017_moirai_module_composition.md | 2 +- packages/lina-core/src/agents/index.ts | 5 +- .../lina-core/src/agents/judgment-store.ts | 35 +- .../test/judgment-candidates.test.ts | 32 +- .../lina-core/test/judgment-deferred.test.ts | 424 ++++++++++++++++++ .../lina-core/test/judgment-store.test.ts | 10 +- 7 files changed, 487 insertions(+), 25 deletions(-) create mode 100644 packages/lina-core/test/judgment-deferred.test.ts diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index f922f6c..d375797 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -86,7 +86,9 @@ Forecast의 결과·비용·기한은 각각 `Claim { claimId, kind: observed | 새 근거가 공통 세계 사실을 바꾸면 snapshot을 무효화하고 새 회차에서 셋 모두에게 공급한다. 새 후보·효과 범위가 추가되면 candidate revision을 올려 해당 평가를 완료한 뒤 선택한다. 평가 횟수·시간·모델·수치 계산 예산과 실패를 기록하며 예산 부족을 가짜 완전성으로 숨기지 않는다. -Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회차 deadline을 먼저 고정한다. 이 예산은 후보 revision이나 무효화 후 후속 회차에서도 같은 원래 요청/자율 활동 슬롯에 누적한다. `closeCandidateSet`이 후보 hash와 coverage를 확정한 뒤에는 새 후보를 같은 선택에 끼워 넣지 못한다. 확정 뒤 제안은 후속 회차에 남기고 실제 전제를 바꾸는 근거만 현재 회차를 무효화한다. 예산 소진 시 `deferred`로 끝내며 선택 RNG·outbox는 진행하지 않는다. 후보 또는 평가가 불완전한 행동 회차도 비실행 종료 기록을 저장할 수 있다. 이때 `SelectionSpec`·순위·양보·충돌 판정은 없고, `holdReason`에 종료 원인을 남긴다. 제공된 후보 근거와 현재성 검사는 그대로 수행한다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. +Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회차 deadline을 먼저 고정한다. 이 예산은 후보 revision이나 무효화 후 후속 회차에서도 같은 원래 요청/자율 활동 슬롯에 누적한다. `closeCandidateSet`이 후보 hash와 coverage를 확정한 뒤에는 새 후보를 같은 선택에 끼워 넣지 못한다. 확정 뒤 제안은 후속 회차에 남기고 실제 전제를 바꾸는 근거만 현재 회차를 무효화한다. 예산 소진 시 `deferred`로 끝내며 선택 RNG·outbox는 진행하지 않는다. 후보 또는 평가가 불완전한 행동 회차도 비실행 종료 기록을 저장할 수 있다. 이때 `SelectionSpec`·순위·양보·충돌 판정은 없고, `holdReason`에 종료 원인을 남긴다. 제공된 후보 근거와 현재성 검사는 그대로 수행한다. + +세 Assessment가 모두 도착했어도 판단 불가가 남으면 policy revision 2의 결과는 `held`다. 예산까지 끝났다면 Host는 이 결과의 `status`만 `deferred`, `holdReason`만 공통 상수 `EVALUATION_BUDGET_EXHAUSTED`로 바꿔 최초 종료 기록을 저장할 수 있다. 나머지 필드는 정책 재생 결과와 같아야 하고 `SelectionSpec`은 없다. 이미 저장한 `held` 회차를 수정하거나 재개하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. `AssessmentSet`은 snapshotId·candidateSetHash·objectiveProfileRefs·모듈별 평가 해시·누락 사유를 묶는다. Host는 현재성·필수 조건으로 적격 후보를 확인한다. 모이라이의 종합 기능은 LLM 해석과 `ArbitrationPolicy`를 포함하며, 각자의 추천을 유지한 채 목표 충돌을 조정한다. 종합 LLM이나 Host가 선언된 정책 밖의 임의 우선순위를 적용하지 않는다. diff --git a/docs/plans/platform/017_moirai_module_composition.md b/docs/plans/platform/017_moirai_module_composition.md index 155f139..f1115e8 100644 --- a/docs/plans/platform/017_moirai_module_composition.md +++ b/docs/plans/platform/017_moirai_module_composition.md @@ -32,7 +32,7 @@ | [현재 MoiraiProbe](../../../packages/lina-codex/src/moirai-probe.ts#L67) | Codex QA 전용 3판단+종합·이력 대조. 제품 채널·도메인 포트는 연결되지 않음. QA 코드를 제품 조정기로 복제하지 않음 | | [PR #10 Senpi](https://github.com/thisisjun786/lina/blob/5b22aee53f9f7c01cc508289099f662aed613140/scripts/qa/senpi-sdk/README.md) | 병합된 비교용 SDK QA. `createAgentSession`·`ModelRuntime`·`SessionManager` 사용 예와 세션·취소·재개 검증. 역할 프롬프트는 이전 렌즈(라케시스=근거, 아트로포스=상황 선택)이므로 F2에서 정본의 목표로 재작성(D13) | | [PR #8 채택 커널](https://github.com/thisisjun786/lina/blob/ab9f1f073eca80ecef8dda0b0f7d338f4d6cb35c/scripts/qa/adoption-kernel/types.ts#L24) | PR은 미병합 종료. `Purpose`·`Adoption(understanding \| plan \| intention)`·`Judgment(method, expectation)`·`ToolReceipt`·`ProposalAction(answer \| adopt \| tool \| defer \| noop)`의 의미를 정본의 `IntentionRecord`와 catalog v1로 이전. Frame·DB·독립 평가 통과는 전제하지 않음 | -| [PR #11 프롬프트 방법론](https://github.com/thisisjun786/lina/blob/25346f15287a96d7e95e8c3e07c51fff1899f66f/docs/plans/platform/014_model_tuning_methodology_research.md) | 병합된 방법론. 작성 순서·A/B/C 진단·비교 자동화·누출 점검을 정본 D20이 채택. 그 문서의 역할 표(라케시스=분석가, 아트로포스=결정자)와 "실행 엔진은 Codex" 문장, 030의 옛 anchor 링크는 병합 시 정본에 맞춰 갱신 필요 | +| [PR #11 프롬프트 방법론](https://github.com/thisisjun786/lina/blob/25346f15287a96d7e95e8c3e07c51fff1899f66f/docs/plans/platform/014_model_tuning_methodology_research.md) | 병합된 방법론. 작성 순서·A/B/C 진단·비교 자동화·누출 점검을 정본 D20이 채택. 그 문서의 이전 역할 표(라케시스=분석가, 아트로포스=결정자)는 F2 프롬프트 작성 때 정본의 세 목표로 바꾼다. 실행 엔진 Codex는 D22와 일치한다 | | PR #8 이후 로컬 검증 후보 `0cdfd43` | 프로세스 복구·실패 결과 검증을 추가한 별도 후보. 제품 의존성으로 채택하지 않고 후속 설계 때 공개 가능한 리비전과 결과를 다시 확인 | | [PR #5 UI](https://github.com/thisisjun786/lina/pull/5) | 별도 UI·공통 client·Electron 구현. 인지 원본을 UI 패키지로 옮기지 않음. 엔진 완성 뒤 고도화 | diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index f2757e0..8cb825c 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -88,7 +88,10 @@ export * from "./judgment-policy.ts"; // Judgment persistence. export { JUDGMENT_SCHEMA_VERSION } from "./judgment-schema.ts"; -export { JudgmentStore } from "./judgment-store.ts"; +export { + EVALUATION_BUDGET_EXHAUSTED, + JudgmentStore, +} from "./judgment-store.ts"; // Behavior identity (pre-existing pure function; exposed so persona contract // tests can pin the BehaviorJobInput fingerprint through the public barrel). diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 5fad73f..0f5985a 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -27,7 +27,7 @@ import { validateDialogueResolution, } from "./judgment-dialogue.ts"; import { validateCandidateEvidence } from "./judgment-evidence.ts"; -import { PERSONAL_POLICY_V1, resolvePersonalRound } from "./judgment-policy.ts"; +import { personalPolicyFor, resolvePersonalRound } from "./judgment-policy.ts"; import { initializeJudgmentSchema } from "./judgment-schema.ts"; import { canonicalJson, @@ -55,6 +55,10 @@ function revision(value: number, minimum = 0): number { return value; } +/** The only holdReason that converts a replayed held record into a terminal + * deferred receipt when the Host's evaluation budget is exhausted. */ +export const EVALUATION_BUDGET_EXHAUSTED = "evaluation budget exhausted"; + function validateResolutionBinding( resolution: StoredResolutionRecord, selection: SelectionSpec | null, @@ -73,12 +77,23 @@ function validateResolutionBinding( // Without complete evidence only a non-executable failure receipt is trusted. // Its descriptive fields are not policy-replayed; deferred is not a fallback. if (!candidates || !set) { - if (resolution.status !== "held" || selection !== null) + if ( + (resolution.status !== "held" && resolution.status !== "deferred") || + selection !== null + ) throw Error(!set ? "incomplete assessment set" : "missing candidate set"); + // An incomplete deferred receipt cannot assert arbitration outcomes. + if ( + resolution.status === "deferred" && + (resolution.ranking.length !== 0 || + resolution.conceded.length !== 0 || + resolution.conflicts.length !== 0) + ) + throw Error("incomplete deferred asserts arbitration outcome"); return; } const replayed = resolvePersonalRound({ - policy: PERSONAL_POLICY_V1, + policy: personalPolicyFor(snapshot.policyId, snapshot.policyRevision), snapshot, options: candidates.options, eligibility: candidates.eligibility, @@ -88,7 +103,19 @@ function validateResolutionBinding( selection?.candidates.map((c) => [c.optionKey, c.b]) ?? [], ), }); - if (body(resolution) !== body(replayed.resolution)) + // A replayed hold may be recorded as a terminal deferred receipt when the + // Host's evaluation budget is exhausted; every other field must match the + // replay exactly, and a resolved replay is never converted. + const budgetExhausted = + replayed.resolution.status === "held" && + resolution.status === "deferred" && + resolution.holdReason === EVALUATION_BUDGET_EXHAUSTED && + body({ + ...resolution, + status: "held", + holdReason: replayed.resolution.holdReason, + }) === body(replayed.resolution); + if (body(resolution) !== body(replayed.resolution) && !budgetExhausted) throw Error("resolution policy replay mismatch"); if (selection === null || replayed.spec === null) { if (selection !== replayed.spec) diff --git a/packages/lina-core/test/judgment-candidates.test.ts b/packages/lina-core/test/judgment-candidates.test.ts index 578c9f9..aaa8b76 100644 --- a/packages/lina-core/test/judgment-candidates.test.ts +++ b/packages/lina-core/test/judgment-candidates.test.ts @@ -360,19 +360,25 @@ test("closure requires an open matching frozen round and is immutable and detach }); for (const complete of [false, true]) - test(`only held/no-spec can record missing candidate evidence (assessments complete=${complete})`, () => { - if (complete) assess(); - expect(() => - store.recordResolution( - snapshot.roundId, - { ...held(), status: "deferred" }, - null, - ), - ).toThrow(); - store.recordResolution(snapshot.roundId, held(), null); - reopen(); - expect(store.getResolution(snapshot.roundId)).toEqual(held()); - }); + for (const status of ["held", "deferred"] as const) + test(`${status}/no-spec can record missing candidate evidence (assessments complete=${complete})`, () => { + if (complete) assess(); + const record = { ...held(), status }; + expect(() => + store.recordResolution( + snapshot.roundId, + { ...held(), status: "invalidated" }, + null, + ), + ).toThrow(); + store.recordResolution(snapshot.roundId, record, null); + expect(() => + store.recordResolution(snapshot.roundId, record, null), + ).toThrow("judgment round is not open"); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(record); + expect(store.getSelectionSpec(snapshot.roundId)).toBeNull(); + }); test("complete inputs replay held and deferred, rejecting descriptive mutations atomically", () => { const set = candidate(); diff --git a/packages/lina-core/test/judgment-deferred.test.ts b/packages/lina-core/test/judgment-deferred.test.ts new file mode 100644 index 0000000..1e63b92 --- /dev/null +++ b/packages/lina-core/test/judgment-deferred.test.ts @@ -0,0 +1,424 @@ +import { afterEach, expect, test } from "bun:test"; +import type { ResolutionRecord } from "../src/agents/judgment.ts"; +import { buildCandidateSet } from "../src/agents/judgment-candidates.ts"; +import { + PERSONAL_POLICY_V2, + resolvePersonalRound, +} from "../src/agents/judgment-policy.ts"; +import { + EVALUATION_BUDGET_EXHAUSTED, + JudgmentStore, +} from "../src/agents/judgment-store.ts"; +import { + assessmentInputDigest, + parseAssessment, + parseAssessmentSet, + parseJudgmentSnapshotRef, + parseResolutionRecord, + snapshotDigest, +} from "../src/agents/judgment-validation.ts"; +import { policyEvidenceFixture } from "./judgment-policy-evidence-fixture.ts"; + +const fixtures: ReturnType[] = []; +afterEach(() => { + for (const { fixture } of fixtures.splice(0)) fixture.close(); +}); + +function deferredRecord( + input: ReturnType["input"], + patch: Partial = {}, +): ResolutionRecord { + return parseResolutionRecord({ + schemaVersion: 1, + roundId: input.snapshot.roundId, + policyId: input.snapshot.policyId, + policyRevision: input.snapshot.policyRevision, + situation: input.snapshot.situation, + order: [...input.policy.orders[input.snapshot.situation]], + recommendations: { clotho: [], lachesis: [], atropos: [] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [], + conceded: [], + status: "deferred", + holdReason: "assessment evidence incomplete", + ...patch, + }); +} + +function v2Round(context: ReturnType) { + const { store, input } = context; + const snapshot = parseJudgmentSnapshotRef({ + ...input.snapshot, + roundId: "policy-evidence-round-v2", + policyRevision: PERSONAL_POLICY_V2.revision, + sequence: input.snapshot.sequence + 1, + }); + store.openRound(snapshot); + const candidates = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options: input.evidence.candidates.options, + eligibility: input.evidence.candidates.eligibility, + intentionRefs: input.evidence.candidates.intentionRefs, + }); + const set = parseAssessmentSet({ + schemaVersion: 1, + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + assessments: input.set.assessments.map((assessment) => { + const ref = { + snapshotDigest: snapshotDigest(snapshot), + objectiveRef: assessment.objectiveRef, + mechanismRevision: assessment.mechanismRevision, + }; + return parseAssessment({ + ...assessment, + snapshotId: snapshot.roundId, + ...ref, + inputDigest: assessmentInputDigest(ref), + }); + }), + }); + return { + snapshot, + candidates, + set, + input: { + ...input, + policy: PERSONAL_POLICY_V2, + snapshot, + set, + evidence: { + candidates, + lookupIntention: input.evidence.lookupIntention, + }, + }, + }; +} + +test("deferred resolution with a partial assessment set records and reopens without a spec", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture, input } = context; + // Only two of three module assessments are stored; no candidate set exists. + for (const assessment of input.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + const record = deferredRecord(input); + + store.recordResolution(input.snapshot.roundId, record, null); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("deferred"); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(input.snapshot.roundId)).toEqual(record); + expect(reopened.getResolution(input.snapshot.roundId, "action")).toEqual( + record, + ); + expect(reopened.getSelectionSpec(input.snapshot.roundId)).toBeNull(); +}); + +test("deferred resolution with a closed candidate set but partial assessments records", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture, input } = context; + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments.slice(0, 1)) + store.putAssessment(assessment); + const record = deferredRecord(input, { + holdReason: "module assessments incomplete", + }); + + store.recordResolution(input.snapshot.roundId, record, null); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getRound(input.snapshot.roundId)?.status).toBe("deferred"); + expect(reopened.getResolution(input.snapshot.roundId)).toEqual(record); + expect(reopened.getSelectionSpec(input.snapshot.roundId)).toBeNull(); +}); + +test("deferred resolution with no candidates and no assessments records", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + const record = deferredRecord(input, { + holdReason: "no candidate evidence", + }); + + store.recordResolution(input.snapshot.roundId, record, null); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("deferred"); + expect(store.getResolution(input.snapshot.roundId)).toEqual(record); +}); + +test("held resolution with a partial assessment set still records", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + for (const assessment of input.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + const record = deferredRecord(input, { + status: "held", + holdReason: "awaiting remaining module", + }); + + store.recordResolution(input.snapshot.roundId, record, null); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("held"); + expect(store.getResolution(input.snapshot.roundId)).toEqual(record); +}); + +test("resolved status with an incomplete assessment set is rejected", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + for (const assessment of input.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + const optionKey = + input.evidence.candidates.options[0]?.optionKey ?? "unknown"; + const resolved = deferredRecord(input, { + status: "resolved", + holdReason: null, + ranking: [{ optionKey, rank: 1 }], + }); + + expect(() => + store.recordResolution(input.snapshot.roundId, resolved, null), + ).toThrow(); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(input.snapshot.roundId)).toBeNull(); +}); + +test("a non-null spec alongside an incomplete assessment set is rejected", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + // A spec is only meaningful for a resolved record; binding one to a + // deferred receipt on a partial set must fail before any write. + const resolved = resolvePersonalRound(input); + if (resolved.spec === null) throw Error("fixture must resolve"); + const record = deferredRecord(input); + + expect(() => + store.recordResolution(input.snapshot.roundId, record, resolved.spec), + ).toThrow(); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(input.snapshot.roundId)).toBeNull(); +}); + +for (const field of ["ranking", "conceded", "conflicts"] as const) { + test(`incomplete deferred with a nonempty ${field} claim is rejected`, () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + for (const assessment of input.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + const optionKey = + input.evidence.candidates.options[0]?.optionKey ?? "unknown"; + const patch: Partial = + field === "ranking" + ? { ranking: [{ optionKey, rank: 1 }] } + : field === "conceded" + ? { conceded: [{ moduleKind: "clotho", optionKey }] } + : { + conflicts: [ + { + optionKey, + stances: { + clotho: "prefer", + lachesis: "accept", + atropos: "accept", + }, + }, + ], + }; + const record = deferredRecord(input, patch); + + expect(() => + store.recordResolution(input.snapshot.roundId, record, null), + ).toThrow(); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(input.snapshot.roundId)).toBeNull(); + }); +} + +test("full candidate and assessment evidence still policy-replays", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture, input } = context; + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(input); + + store.recordResolution( + input.snapshot.roundId, + result.resolution, + result.spec, + ); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(input.snapshot.roundId)).toEqual( + result.resolution, + ); + expect(reopened.getSelectionSpec(input.snapshot.roundId)).toEqual( + result.spec, + ); +}); + +test("a revision-2 snapshot replays against the frozen v2 policy", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture } = context; + const round = v2Round(context); + // Under v2 an unavailable module opinion on a remaining candidate holds the + // round; under v1 that module would be dropped and the round resolved. + const target = round.set.assessments.find( + (assessment) => assessment.moduleKind === "lachesis", + ); + const opinion = target?.objectiveAssessments[0]; + if (!opinion) throw Error("missing fixture opinion"); + opinion.stance = "unavailable"; + opinion.unavailableReason = "insufficient_evidence"; + store.closeCandidateSet(round.candidates); + for (const assessment of round.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(round.input); + expect(result.resolution.status).toBe("held"); + expect(result.spec).toBeNull(); + + store.recordResolution( + round.snapshot.roundId, + result.resolution, + result.spec, + ); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(round.snapshot.roundId)).toEqual( + result.resolution, + ); + expect(reopened.getSelectionSpec(round.snapshot.roundId)).toBeNull(); +}); + +test("mixed v1 and v2 rounds replay under their own frozen revisions", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture, input } = context; + // Round 1: v1 resolves normally with full evidence. + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const v1 = resolvePersonalRound(input); + store.recordResolution(input.snapshot.roundId, v1.resolution, v1.spec); + // Round 2: v2 holds on an unavailable opinion. + const round = v2Round(context); + const target = round.set.assessments.find( + (assessment) => assessment.moduleKind === "atropos", + ); + const opinion = target?.objectiveAssessments[0]; + if (!opinion) throw Error("missing fixture opinion"); + opinion.stance = "unavailable"; + opinion.unavailableReason = "insufficient_evidence"; + store.closeCandidateSet(round.candidates); + for (const assessment of round.set.assessments) + store.putAssessment(assessment); + const v2 = resolvePersonalRound(round.input); + expect(v2.resolution.status).toBe("held"); + store.recordResolution(round.snapshot.roundId, v2.resolution, v2.spec); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(input.snapshot.roundId)).toEqual(v1.resolution); + expect(reopened.getSelectionSpec(input.snapshot.roundId)).toEqual(v1.spec); + expect(reopened.getResolution(round.snapshot.roundId)).toEqual(v2.resolution); + expect(reopened.getRound(round.snapshot.roundId)?.status).toBe("held"); +}); + +function v2HeldRound(context: ReturnType) { + const { store } = context; + const round = v2Round(context); + const target = round.set.assessments.find( + (assessment) => assessment.moduleKind === "lachesis", + ); + const opinion = target?.objectiveAssessments[0]; + if (!opinion) throw Error("missing fixture opinion"); + opinion.stance = "unavailable"; + opinion.unavailableReason = "insufficient_evidence"; + store.closeCandidateSet(round.candidates); + for (const assessment of round.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(round.input); + if (result.resolution.status !== "held" || result.spec !== null) + throw Error("v2 fixture must hold"); + return { ...round, held: result.resolution }; +} + +test("a replayed v2 hold converts to terminal deferred on budget exhaustion", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture } = context; + const round = v2HeldRound(context); + const record = parseResolutionRecord({ + ...round.held, + status: "deferred", + holdReason: EVALUATION_BUDGET_EXHAUSTED, + }); + + store.recordResolution(round.snapshot.roundId, record, null); + + expect(store.getRound(round.snapshot.roundId)?.status).toBe("deferred"); + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(round.snapshot.roundId)).toEqual(record); + expect(reopened.getSelectionSpec(round.snapshot.roundId)).toBeNull(); +}); + +test("budget exhaustion preserves replayed abstentions and rejects field drift", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store } = context; + const round = v2HeldRound(context); + // The replayed hold carries the unavailable opinion as an abstention; the + // converted record must keep it verbatim. + expect(round.held.abstentions.length).toBeGreaterThan(0); + const drifted = parseResolutionRecord({ + ...round.held, + abstentions: [], + status: "deferred", + holdReason: EVALUATION_BUDGET_EXHAUSTED, + }); + expect(() => + store.recordResolution(round.snapshot.roundId, drifted, null), + ).toThrow(); + const wrongReason = parseResolutionRecord({ + ...round.held, + status: "deferred", + holdReason: "host gave up", + }); + expect(() => + store.recordResolution(round.snapshot.roundId, wrongReason, null), + ).toThrow(); + expect(store.getRound(round.snapshot.roundId)?.status).toBe("open"); +}); + +test("a replayed resolved record is never converted to deferred", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(input); + if (result.resolution.status !== "resolved") + throw Error("fixture must resolve"); + const record = parseResolutionRecord({ + ...result.resolution, + status: "deferred", + holdReason: EVALUATION_BUDGET_EXHAUSTED, + }); + + expect(() => + store.recordResolution(input.snapshot.roundId, record, null), + ).toThrow(); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(input.snapshot.roundId)).toBeNull(); +}); diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index 44cd522..4e93601 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -1585,21 +1585,21 @@ for (const reopen of [false, true]) { test("3995958855: no baseline is guessed for undeclared policy revisions", () => { const store = open(); - const ref = { ...snapshot(store), policyRevision: 2 }; + const ref = { ...snapshot(store), policyRevision: 3 }; store.openRound(ref); closeCandidates(store, ref); for (const module of MODULE_KINDS) store.putAssessment(assessment(ref, module)); const record = parseResolutionRecord({ ...resolution(ref), - policyRevision: 2, + policyRevision: 3, }); const selection = rehashSelection({ ...spec(ref, record), - policyRevision: 2, + policyRevision: 3, }); expect(() => store.recordResolution(ref.roundId, record, selection)).toThrow( - "policy snapshot mismatch", + "unsupported personal policy declaration", ); expect(() => store.recordResolution( @@ -1607,7 +1607,7 @@ test("3995958855: no baseline is guessed for undeclared policy revisions", () => { ...record, status: "held", holdReason: "no policy declaration" }, null, ), - ).toThrow("policy snapshot mismatch"); + ).toThrow("unsupported personal policy declaration"); expect(store.getRound(ref.roundId)?.status).toBe("open"); expect(store.getResolution(ref.roundId)).toBeNull(); }); From 4ed1ff81eca147a8aa420ea75135fefa3e816d20 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:54:47 +0900 Subject: [PATCH 03/12] fix(core): reserve generated readout provenance --- .../lina-core/src/agents/judgment-output.ts | 2 ++ .../lina-core/test/judgment-alignment.test.ts | 23 +++++++++++++++++++ .../test/judgment-candidates.test.ts | 6 +---- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-output.ts b/packages/lina-core/src/agents/judgment-output.ts index 3f1b9b0..0c099e8 100644 --- a/packages/lina-core/src/agents/judgment-output.ts +++ b/packages/lina-core/src/agents/judgment-output.ts @@ -15,6 +15,8 @@ export function buildAssessment( inputDigest: assessmentInputDigest(input), completeText: readout.text, }); + if (Object.hasOwn(parsed.diagnostics, "readoutTruncation")) + throw Error("fresh assessment must not supply truncation metadata"); if (readout.truncation) parsed.diagnostics = { ...parsed.diagnostics, diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts index 74d2729..e7c0a41 100644 --- a/packages/lina-core/test/judgment-alignment.test.ts +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -120,3 +120,26 @@ test("fresh readout clips prose with provenance and leaves structured fields int api.buildAssessment({ ...raw, recommendedOptionKeys: ["forged"] }), ).toThrow(); }); + +test("fresh assessments reject supplied truncation provenance while legacy parsers preserve diagnostics", () => { + const { input } = setup(); + const source = input.set.assessments[0]; + if (!source) throw Error("missing fixture assessment"); + const forged = { + ...source, + diagnostics: { + readoutTruncation: { + originalLength: 5000, + limit: 4000, + sourceDigest: "a".repeat(64), + }, + }, + }; + // Legacy diagnostics stay readable; fresh output owns its provenance. + expect(api.parseAssessment(forged)).toEqual(forged); + const { inputDigest: _digest, ...raw } = forged; + for (const completeText of ["text", "a".repeat(5000)]) + expect(() => api.buildAssessment({ ...raw, completeText })).toThrow( + "fresh assessment must not supply truncation metadata", + ); +}); diff --git a/packages/lina-core/test/judgment-candidates.test.ts b/packages/lina-core/test/judgment-candidates.test.ts index aaa8b76..3b0a2bd 100644 --- a/packages/lina-core/test/judgment-candidates.test.ts +++ b/packages/lina-core/test/judgment-candidates.test.ts @@ -365,11 +365,7 @@ for (const complete of [false, true]) if (complete) assess(); const record = { ...held(), status }; expect(() => - store.recordResolution( - snapshot.roundId, - { ...held(), status: "invalidated" }, - null, - ), + parseResolutionRecord({ ...held(), status: "invalidated" }), ).toThrow(); store.recordResolution(snapshot.roundId, record, null); expect(() => From 67794782ce6d7c3f8f72ec0456af3b1b57f2c7a7 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:47:37 +0900 Subject: [PATCH 04/12] fix(core): bind incomplete deferred receipts to stored evidence Automated review on 4ed1ff8 found two defects in the new contracts. An incomplete deferred receipt is terminal but still trusted its order, recommendations, exclusions and abstentions, so a caller could preserve arbitration claims that no stored evidence supports. Budget conversion was also ungated on policy revision, letting a revision-1 replayed hold become a terminal budget-exhausted receipt that revision 1 never declared. An incomplete deferred receipt now must restate the declared policy order, must match the recommendations its stored assessments prove, and must leave exclusions, abstentions, conflicts, ranking and concessions empty. Budget conversion requires revision 2 or higher. Held receipts keep their PR14 descriptive latitude so historical action bytes and digests stay readable. Four regressions failed first, then passed; a positive case keeps proven recommendations. Judgment suite 631 pass, typecheck, lint and build pass. --- .../016_neural_preference_contract.md | 6 +- .../lina-core/src/agents/judgment-store.ts | 44 +++++++--- .../lina-core/test/judgment-deferred.test.ts | 88 +++++++++++++++++++ 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index d375797..4e71d8d 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. @@ -88,7 +88,9 @@ Forecast의 결과·비용·기한은 각각 `Claim { claimId, kind: observed | Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회차 deadline을 먼저 고정한다. 이 예산은 후보 revision이나 무효화 후 후속 회차에서도 같은 원래 요청/자율 활동 슬롯에 누적한다. `closeCandidateSet`이 후보 hash와 coverage를 확정한 뒤에는 새 후보를 같은 선택에 끼워 넣지 못한다. 확정 뒤 제안은 후속 회차에 남기고 실제 전제를 바꾸는 근거만 현재 회차를 무효화한다. 예산 소진 시 `deferred`로 끝내며 선택 RNG·outbox는 진행하지 않는다. 후보 또는 평가가 불완전한 행동 회차도 비실행 종료 기록을 저장할 수 있다. 이때 `SelectionSpec`·순위·양보·충돌 판정은 없고, `holdReason`에 종료 원인을 남긴다. 제공된 후보 근거와 현재성 검사는 그대로 수행한다. -세 Assessment가 모두 도착했어도 판단 불가가 남으면 policy revision 2의 결과는 `held`다. 예산까지 끝났다면 Host는 이 결과의 `status`만 `deferred`, `holdReason`만 공통 상수 `EVALUATION_BUDGET_EXHAUSTED`로 바꿔 최초 종료 기록을 저장할 수 있다. 나머지 필드는 정책 재생 결과와 같아야 하고 `SelectionSpec`은 없다. 이미 저장한 `held` 회차를 수정하거나 재개하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. +세 Assessment가 모두 도착했어도 판단 불가가 남으면 policy revision 2의 결과는 `held`다. 예산까지 끝났다면 Host는 이 결과의 `status`만 `deferred`, `holdReason`만 공통 상수 `EVALUATION_BUDGET_EXHAUSTED`로 바꿔 최초 종료 기록을 저장할 수 있다. 나머지 필드는 정책 재생 결과와 같아야 하고 `SelectionSpec`은 없다. 예산 소진 전환은 revision 2의 규칙이므로 revision 1로 재생한 `held`는 전환하지 않고 재시도 가능한 상태로 남긴다. 이미 저장한 `held` 회차를 수정하거나 재개하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. + +증거가 불완전한 회차는 정책 재생이 불가능하므로 그 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. `AssessmentSet`은 snapshotId·candidateSetHash·objectiveProfileRefs·모듈별 평가 해시·누락 사유를 묶는다. Host는 현재성·필수 조건으로 적격 후보를 확인한다. 모이라이의 종합 기능은 LLM 해석과 `ArbitrationPolicy`를 포함하며, 각자의 추천을 유지한 채 목표 충돌을 조정한다. 종합 LLM이나 Host가 선언된 정책 밖의 임의 우선순위를 적용하지 않는다. diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 0f5985a..b0bfad4 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -74,26 +74,43 @@ function validateResolutionBinding( validateDialogueResolution(resolution, snapshot, assessments); return; } + const policy = personalPolicyFor(snapshot.policyId, snapshot.policyRevision); // Without complete evidence only a non-executable failure receipt is trusted. - // Its descriptive fields are not policy-replayed; deferred is not a fallback. + // It cannot be policy-replayed, so the terminal deferred receipt introduced + // here may restate only the declared order and what its stored assessments + // prove. Held rows keep their original latitude so legacy bytes stay readable. if (!candidates || !set) { if ( (resolution.status !== "held" && resolution.status !== "deferred") || selection !== null ) throw Error(!set ? "incomplete assessment set" : "missing candidate set"); - // An incomplete deferred receipt cannot assert arbitration outcomes. - if ( - resolution.status === "deferred" && - (resolution.ranking.length !== 0 || - resolution.conceded.length !== 0 || - resolution.conflicts.length !== 0) - ) - throw Error("incomplete deferred asserts arbitration outcome"); + if (resolution.status === "deferred") { + const recommendations: Record = { + clotho: [], + lachesis: [], + atropos: [], + }; + for (const assessment of assessments) + recommendations[assessment.moduleKind] = [ + ...assessment.recommendedOptionKeys, + ]; + if ( + body(resolution.order) !== + body([...policy.orders[snapshot.situation]]) || + body(resolution.recommendations) !== body(recommendations) || + resolution.excluded.length !== 0 || + resolution.abstentions.length !== 0 || + resolution.conflicts.length !== 0 || + resolution.ranking.length !== 0 || + resolution.conceded.length !== 0 + ) + throw Error("incomplete deferred asserts unsupported arbitration"); + } return; } const replayed = resolvePersonalRound({ - policy: personalPolicyFor(snapshot.policyId, snapshot.policyRevision), + policy, snapshot, options: candidates.options, eligibility: candidates.eligibility, @@ -103,10 +120,11 @@ function validateResolutionBinding( selection?.candidates.map((c) => [c.optionKey, c.b]) ?? [], ), }); - // A replayed hold may be recorded as a terminal deferred receipt when the - // Host's evaluation budget is exhausted; every other field must match the - // replay exactly, and a resolved replay is never converted. + // Budget exhaustion is a revision 2 rule: only a hold that policy replayed + // under it becomes terminal, every other field still matches the replay, and + // a resolved replay is never converted. Revision 1 holds stay retryable. const budgetExhausted = + policy.revision >= 2 && replayed.resolution.status === "held" && resolution.status === "deferred" && resolution.holdReason === EVALUATION_BUDGET_EXHAUSTED && diff --git a/packages/lina-core/test/judgment-deferred.test.ts b/packages/lina-core/test/judgment-deferred.test.ts index 1e63b92..955f1c9 100644 --- a/packages/lina-core/test/judgment-deferred.test.ts +++ b/packages/lina-core/test/judgment-deferred.test.ts @@ -422,3 +422,91 @@ test("a replayed resolved record is never converted to deferred", () => { expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); expect(store.getResolution(input.snapshot.roundId)).toBeNull(); }); + +test("a replayed revision-1 hold is never converted by budget exhaustion", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + // The eligible option loses one module opinion, so revision 1 also holds. + const target = input.set.assessments.find((a) => a.moduleKind === "lachesis"); + if (!target) throw Error("missing fixture assessment"); + target.objectiveAssessments = []; + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const replayed = resolvePersonalRound(input); + expect(input.snapshot.policyRevision).toBe(1); + expect(replayed.resolution.status).toBe("held"); + const record = parseResolutionRecord({ + ...replayed.resolution, + status: "deferred", + holdReason: EVALUATION_BUDGET_EXHAUSTED, + }); + + expect(() => + store.recordResolution(input.snapshot.roundId, record, null), + ).toThrow("resolution policy replay mismatch"); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(input.snapshot.roundId)).toBeNull(); +}); + +test("an incomplete deferred receipt rejects unsupported arbitration fields", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input, option } = context; + // Only clotho and lachesis are stored, and no candidate set exists. + for (const assessment of input.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + const optionKey = option.optionKey; + for (const patch of [ + { + excluded: [ + { + optionKey, + stage: "host_eligibility" as const, + byModule: null, + reason: "host ineligible", + }, + ], + }, + { + abstentions: [ + { optionKey, moduleKind: "lachesis" as const, reason: "unavailable" }, + ], + }, + // atropos has no stored assessment, so it can recommend nothing. + { recommendations: { clotho: [], lachesis: [], atropos: [optionKey] } }, + // The declared order for this round's situation is not the transition one. + { order: [...PERSONAL_POLICY_V2.orders.transition] }, + ]) { + const record = deferredRecord(input, patch); + expect(() => + store.recordResolution(input.snapshot.roundId, record, null), + ).toThrow("incomplete deferred asserts unsupported arbitration"); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(input.snapshot.roundId)).toBeNull(); + } +}); + +test("an incomplete receipt keeps the recommendations its stored assessments prove", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture, input, option } = context; + const stored = input.set.assessments.slice(0, 2).map((assessment) => + parseAssessment({ + ...assessment, + recommendedOptionKeys: + assessment.moduleKind === "clotho" ? [option.optionKey] : [], + }), + ); + for (const assessment of stored) store.putAssessment(assessment); + const record = deferredRecord(input, { + recommendations: { clotho: [option.optionKey], lachesis: [], atropos: [] }, + }); + + store.recordResolution(input.snapshot.roundId, record, null); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(input.snapshot.roundId)).toEqual(record); + expect(reopened.getSelectionSpec(input.snapshot.roundId)).toBeNull(); +}); From 70a046752e9ceecb1bfed2d55d9237348909bc8c Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:54:28 +0900 Subject: [PATCH 05/12] fix(core): scope terminal deferred receipts to policy revision 2 Follow-up review found the incomplete-evidence branch admitted a terminal deferred receipt under any policy revision. Revision 1 is kept only for historical replay and ended such rounds as held, so closing one as deferred permanently stopped work that revision 1 left retryable. Incomplete evidence now yields a terminal receipt only under revision 2, matching the budget-conversion gate, while held stays available for every revision. The incomplete-evidence cases moved onto revision-2 rounds, a new test covers the revision-1 rejection, and the candidate suite returns to its revision-1 contract with the closed-round and null-selection checks kept. Judgment suite 630 pass; typecheck, lint and build pass. --- .../016_neural_preference_contract.md | 2 +- .../lina-core/src/agents/judgment-store.ts | 15 ++- .../test/judgment-candidates.test.ts | 37 ++++--- .../lina-core/test/judgment-deferred.test.ts | 103 +++++++++++------- 4 files changed, 96 insertions(+), 61 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index 4e71d8d..9b23056 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -90,7 +90,7 @@ Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회 세 Assessment가 모두 도착했어도 판단 불가가 남으면 policy revision 2의 결과는 `held`다. 예산까지 끝났다면 Host는 이 결과의 `status`만 `deferred`, `holdReason`만 공통 상수 `EVALUATION_BUDGET_EXHAUSTED`로 바꿔 최초 종료 기록을 저장할 수 있다. 나머지 필드는 정책 재생 결과와 같아야 하고 `SelectionSpec`은 없다. 예산 소진 전환은 revision 2의 규칙이므로 revision 1로 재생한 `held`는 전환하지 않고 재시도 가능한 상태로 남긴다. 이미 저장한 `held` 회차를 수정하거나 재개하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. -증거가 불완전한 회차는 정책 재생이 불가능하므로 그 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. +증거가 불완전한 회차의 비실행 종료 기록도 policy revision 2의 규칙이다. revision 1 회차는 재시도 가능한 `held`로만 끝나며 새 종료 상태로 닫지 않는다. 정책 재생이 불가능하므로 revision 2의 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. `AssessmentSet`은 snapshotId·candidateSetHash·objectiveProfileRefs·모듈별 평가 해시·누락 사유를 묶는다. Host는 현재성·필수 조건으로 적격 후보를 확인한다. 모이라이의 종합 기능은 LLM 해석과 `ArbitrationPolicy`를 포함하며, 각자의 추천을 유지한 채 목표 충돌을 조정한다. 종합 LLM이나 Host가 선언된 정책 밖의 임의 우선순위를 적용하지 않는다. diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index b0bfad4..042447e 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -76,16 +76,15 @@ function validateResolutionBinding( } const policy = personalPolicyFor(snapshot.policyId, snapshot.policyRevision); // Without complete evidence only a non-executable failure receipt is trusted. - // It cannot be policy-replayed, so the terminal deferred receipt introduced - // here may restate only the declared order and what its stored assessments - // prove. Held rows keep their original latitude so legacy bytes stay readable. + // Revision 2 owns the terminal receipt, and it cannot be policy-replayed, so + // it may restate only the declared order and what its stored assessments + // prove. Held rows stay retryable for every revision and keep their original + // latitude so historical bytes remain readable. if (!candidates || !set) { - if ( - (resolution.status !== "held" && resolution.status !== "deferred") || - selection !== null - ) + const terminal = policy.revision >= 2 && resolution.status === "deferred"; + if ((resolution.status !== "held" && !terminal) || selection !== null) throw Error(!set ? "incomplete assessment set" : "missing candidate set"); - if (resolution.status === "deferred") { + if (terminal) { const recommendations: Record = { clotho: [], lachesis: [], diff --git a/packages/lina-core/test/judgment-candidates.test.ts b/packages/lina-core/test/judgment-candidates.test.ts index 3b0a2bd..5cb4f9b 100644 --- a/packages/lina-core/test/judgment-candidates.test.ts +++ b/packages/lina-core/test/judgment-candidates.test.ts @@ -359,22 +359,29 @@ test("closure requires an open matching frozen round and is immutable and detach expect(store.candidateSet(snapshot.roundId)).toEqual(candidate()); }); +// This round declares policy revision 1, which owns no terminal receipt. for (const complete of [false, true]) - for (const status of ["held", "deferred"] as const) - test(`${status}/no-spec can record missing candidate evidence (assessments complete=${complete})`, () => { - if (complete) assess(); - const record = { ...held(), status }; - expect(() => - parseResolutionRecord({ ...held(), status: "invalidated" }), - ).toThrow(); - store.recordResolution(snapshot.roundId, record, null); - expect(() => - store.recordResolution(snapshot.roundId, record, null), - ).toThrow("judgment round is not open"); - reopen(); - expect(store.getResolution(snapshot.roundId)).toEqual(record); - expect(store.getSelectionSpec(snapshot.roundId)).toBeNull(); - }); + test(`only held/no-spec can record missing candidate evidence (assessments complete=${complete})`, () => { + if (complete) assess(); + expect(() => + parseResolutionRecord({ ...held(), status: "invalidated" }), + ).toThrow(); + expect(() => + store.recordResolution( + snapshot.roundId, + { ...held(), status: "deferred" }, + null, + ), + ).toThrow(complete ? "missing candidate set" : "incomplete assessment set"); + const record = held(); + store.recordResolution(snapshot.roundId, record, null); + expect(() => + store.recordResolution(snapshot.roundId, record, null), + ).toThrow("judgment round is not open"); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(record); + expect(store.getSelectionSpec(snapshot.roundId)).toBeNull(); + }); test("complete inputs replay held and deferred, rejecting descriptive mutations atomically", () => { const set = candidate(); diff --git a/packages/lina-core/test/judgment-deferred.test.ts b/packages/lina-core/test/judgment-deferred.test.ts index 955f1c9..62202c2 100644 --- a/packages/lina-core/test/judgment-deferred.test.ts +++ b/packages/lina-core/test/judgment-deferred.test.ts @@ -101,53 +101,78 @@ function v2Round(context: ReturnType) { test("deferred resolution with a partial assessment set records and reopens without a spec", () => { const context = policyEvidenceFixture(); fixtures.push(context); - const { store, path, fixture, input } = context; + const { store, path, fixture } = context; // Only two of three module assessments are stored; no candidate set exists. - for (const assessment of input.set.assessments.slice(0, 2)) + const round = v2Round(context); + for (const assessment of round.set.assessments.slice(0, 2)) store.putAssessment(assessment); - const record = deferredRecord(input); + const record = deferredRecord(round.input); - store.recordResolution(input.snapshot.roundId, record, null); - expect(store.getRound(input.snapshot.roundId)?.status).toBe("deferred"); + store.recordResolution(round.snapshot.roundId, record, null); + expect(store.getRound(round.snapshot.roundId)?.status).toBe("deferred"); const reopened = fixture.keep(new JudgmentStore(path)); - expect(reopened.getResolution(input.snapshot.roundId)).toEqual(record); - expect(reopened.getResolution(input.snapshot.roundId, "action")).toEqual( + expect(reopened.getResolution(round.snapshot.roundId)).toEqual(record); + expect(reopened.getResolution(round.snapshot.roundId, "action")).toEqual( record, ); - expect(reopened.getSelectionSpec(input.snapshot.roundId)).toBeNull(); + expect(reopened.getSelectionSpec(round.snapshot.roundId)).toBeNull(); }); test("deferred resolution with a closed candidate set but partial assessments records", () => { const context = policyEvidenceFixture(); fixtures.push(context); - const { store, path, fixture, input } = context; - store.closeCandidateSet(input.evidence.candidates); - for (const assessment of input.set.assessments.slice(0, 1)) + const { store, path, fixture } = context; + const round = v2Round(context); + store.closeCandidateSet(round.candidates); + for (const assessment of round.set.assessments.slice(0, 1)) store.putAssessment(assessment); - const record = deferredRecord(input, { + const record = deferredRecord(round.input, { holdReason: "module assessments incomplete", }); - store.recordResolution(input.snapshot.roundId, record, null); + store.recordResolution(round.snapshot.roundId, record, null); const reopened = fixture.keep(new JudgmentStore(path)); - expect(reopened.getRound(input.snapshot.roundId)?.status).toBe("deferred"); - expect(reopened.getResolution(input.snapshot.roundId)).toEqual(record); - expect(reopened.getSelectionSpec(input.snapshot.roundId)).toBeNull(); + expect(reopened.getRound(round.snapshot.roundId)?.status).toBe("deferred"); + expect(reopened.getResolution(round.snapshot.roundId)).toEqual(record); + expect(reopened.getSelectionSpec(round.snapshot.roundId)).toBeNull(); }); test("deferred resolution with no candidates and no assessments records", () => { const context = policyEvidenceFixture(); fixtures.push(context); - const { store, input } = context; - const record = deferredRecord(input, { + const { store } = context; + const round = v2Round(context); + const record = deferredRecord(round.input, { holdReason: "no candidate evidence", }); - store.recordResolution(input.snapshot.roundId, record, null); - expect(store.getRound(input.snapshot.roundId)?.status).toBe("deferred"); - expect(store.getResolution(input.snapshot.roundId)).toEqual(record); + store.recordResolution(round.snapshot.roundId, record, null); + expect(store.getRound(round.snapshot.roundId)?.status).toBe("deferred"); + expect(store.getResolution(round.snapshot.roundId)).toEqual(record); +}); + +test("a revision-1 incomplete round ends held and never deferred", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, input } = context; + expect(input.snapshot.policyRevision).toBe(1); + for (const assessment of input.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + // Revision 1 declares no terminal receipt, so its rounds stay retryable. + expect(() => + store.recordResolution(input.snapshot.roundId, deferredRecord(input), null), + ).toThrow("incomplete assessment set"); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); + + const held = deferredRecord(input, { + status: "held", + holdReason: "awaiting remaining module", + }); + store.recordResolution(input.snapshot.roundId, held, null); + expect(store.getRound(input.snapshot.roundId)?.status).toBe("held"); + expect(store.getResolution(input.snapshot.roundId)).toEqual(held); }); test("held resolution with a partial assessment set still records", () => { @@ -212,7 +237,8 @@ for (const field of ["ranking", "conceded", "conflicts"] as const) { const context = policyEvidenceFixture(); fixtures.push(context); const { store, input } = context; - for (const assessment of input.set.assessments.slice(0, 2)) + const round = v2Round(context); + for (const assessment of round.set.assessments.slice(0, 2)) store.putAssessment(assessment); const optionKey = input.evidence.candidates.options[0]?.optionKey ?? "unknown"; @@ -233,13 +259,13 @@ for (const field of ["ranking", "conceded", "conflicts"] as const) { }, ], }; - const record = deferredRecord(input, patch); + const record = deferredRecord(round.input, patch); expect(() => - store.recordResolution(input.snapshot.roundId, record, null), + store.recordResolution(round.snapshot.roundId, record, null), ).toThrow(); - expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); - expect(store.getResolution(input.snapshot.roundId)).toBeNull(); + expect(store.getRound(round.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(round.snapshot.roundId)).toBeNull(); }); } @@ -454,8 +480,9 @@ test("an incomplete deferred receipt rejects unsupported arbitration fields", () const context = policyEvidenceFixture(); fixtures.push(context); const { store, input, option } = context; + const round = v2Round(context); // Only clotho and lachesis are stored, and no candidate set exists. - for (const assessment of input.set.assessments.slice(0, 2)) + for (const assessment of round.set.assessments.slice(0, 2)) store.putAssessment(assessment); const optionKey = option.optionKey; for (const patch of [ @@ -479,20 +506,22 @@ test("an incomplete deferred receipt rejects unsupported arbitration fields", () // The declared order for this round's situation is not the transition one. { order: [...PERSONAL_POLICY_V2.orders.transition] }, ]) { - const record = deferredRecord(input, patch); + const record = deferredRecord(round.input, patch); expect(() => - store.recordResolution(input.snapshot.roundId, record, null), + store.recordResolution(round.snapshot.roundId, record, null), ).toThrow("incomplete deferred asserts unsupported arbitration"); - expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); - expect(store.getResolution(input.snapshot.roundId)).toBeNull(); + expect(store.getRound(round.snapshot.roundId)?.status).toBe("open"); + expect(store.getResolution(round.snapshot.roundId)).toBeNull(); } + expect(input.snapshot.policyRevision).toBe(1); }); test("an incomplete receipt keeps the recommendations its stored assessments prove", () => { const context = policyEvidenceFixture(); fixtures.push(context); - const { store, path, fixture, input, option } = context; - const stored = input.set.assessments.slice(0, 2).map((assessment) => + const { store, path, fixture, option } = context; + const round = v2Round(context); + const stored = round.set.assessments.slice(0, 2).map((assessment) => parseAssessment({ ...assessment, recommendedOptionKeys: @@ -500,13 +529,13 @@ test("an incomplete receipt keeps the recommendations its stored assessments pro }), ); for (const assessment of stored) store.putAssessment(assessment); - const record = deferredRecord(input, { + const record = deferredRecord(round.input, { recommendations: { clotho: [option.optionKey], lachesis: [], atropos: [] }, }); - store.recordResolution(input.snapshot.roundId, record, null); + store.recordResolution(round.snapshot.roundId, record, null); const reopened = fixture.keep(new JudgmentStore(path)); - expect(reopened.getResolution(input.snapshot.roundId)).toEqual(record); - expect(reopened.getSelectionSpec(input.snapshot.roundId)).toBeNull(); + expect(reopened.getResolution(round.snapshot.roundId)).toEqual(record); + expect(reopened.getSelectionSpec(round.snapshot.roundId)).toBeNull(); }); From c51e779043b5b0fbacebbd9c005e43a652423324 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:09:41 +0900 Subject: [PATCH 06/12] fix(core): require an exhausted budget and spare legacy holds Follow-up review found two more defects. An incomplete revision-2 round accepted any deferred reason, so a caller could end a round that still had evaluation budget, and recording a resolution then blocks the missing assessment. The policy lookup also ran for held rows, and because the ledger audit runs inside every transaction, one historical hold recorded under a catalog or revision this binary does not declare would fail unrelated reads. A terminal receipt for incomplete evidence now requires the shared EVALUATION_BUDGET_EXHAUSTED reason, so any other interruption stays held and the round keeps its remaining work. The held path resolves no policy; the lookup runs only for that terminal receipt and for complete replay. Three regressions failed first, then passed, including a revision-3 hold that reopens and still serves unrelated reads. Judgment suite 632 pass; typecheck, lint and build pass. --- .../016_neural_preference_contract.md | 2 +- .../lina-core/src/agents/judgment-store.ts | 76 +++++++++++-------- .../test/judgment-candidates.test.ts | 2 +- .../lina-core/test/judgment-deferred.test.ts | 71 ++++++++++++++--- 4 files changed, 106 insertions(+), 45 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index 9b23056..53564bc 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -90,7 +90,7 @@ Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회 세 Assessment가 모두 도착했어도 판단 불가가 남으면 policy revision 2의 결과는 `held`다. 예산까지 끝났다면 Host는 이 결과의 `status`만 `deferred`, `holdReason`만 공통 상수 `EVALUATION_BUDGET_EXHAUSTED`로 바꿔 최초 종료 기록을 저장할 수 있다. 나머지 필드는 정책 재생 결과와 같아야 하고 `SelectionSpec`은 없다. 예산 소진 전환은 revision 2의 규칙이므로 revision 1로 재생한 `held`는 전환하지 않고 재시도 가능한 상태로 남긴다. 이미 저장한 `held` 회차를 수정하거나 재개하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. -증거가 불완전한 회차의 비실행 종료 기록도 policy revision 2의 규칙이다. revision 1 회차는 재시도 가능한 `held`로만 끝나며 새 종료 상태로 닫지 않는다. 정책 재생이 불가능하므로 revision 2의 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. +증거가 불완전한 회차의 비실행 종료 기록도 policy revision 2의 규칙이다. revision 1 회차는 재시도 가능한 `held`로만 끝나며 새 종료 상태로 닫지 않는다. 종료 기록의 `holdReason`은 `EVALUATION_BUDGET_EXHAUSTED`여야 한다. 예산이 남아 있으면 회차를 열어 두고 보완하며, 다른 사유의 중단은 `held`로 남긴다. 기록을 남기면 그 회차에는 더 이상 평가를 저장할 수 없으므로 남은 보완 기회를 임의로 버리지 않는다. 정책 재생이 불가능하므로 revision 2의 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. `held` 경로는 정책 선언을 조회하지 않는다. 이 binary가 모르는 catalog·revision으로 기록된 과거 행 하나가 저장소의 다른 읽기까지 막지 않아야 한다. `AssessmentSet`은 snapshotId·candidateSetHash·objectiveProfileRefs·모듈별 평가 해시·누락 사유를 묶는다. Host는 현재성·필수 조건으로 적격 후보를 확인한다. 모이라이의 종합 기능은 LLM 해석과 `ArbitrationPolicy`를 포함하며, 각자의 추천을 유지한 채 목표 충돌을 조정한다. 종합 LLM이나 Host가 선언된 정책 밖의 임의 우선순위를 적용하지 않는다. diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 042447e..25ccaa3 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -55,8 +55,8 @@ function revision(value: number, minimum = 0): number { return value; } -/** The only holdReason that converts a replayed held record into a terminal - * deferred receipt when the Host's evaluation budget is exhausted. */ +/** The only holdReason that ends a round as a terminal deferred receipt, whether + * its evidence is incomplete or it replayed to a hold under revision 2. */ export const EVALUATION_BUDGET_EXHAUSTED = "evaluation budget exhausted"; function validateResolutionBinding( @@ -74,40 +74,52 @@ function validateResolutionBinding( validateDialogueResolution(resolution, snapshot, assessments); return; } - const policy = personalPolicyFor(snapshot.policyId, snapshot.policyRevision); - // Without complete evidence only a non-executable failure receipt is trusted. - // Revision 2 owns the terminal receipt, and it cannot be policy-replayed, so - // it may restate only the declared order and what its stored assessments - // prove. Held rows stay retryable for every revision and keep their original - // latitude so historical bytes remain readable. + // Without complete evidence only a non-executable receipt is trusted, and it + // never carries a selection. if (!candidates || !set) { - const terminal = policy.revision >= 2 && resolution.status === "deferred"; - if ((resolution.status !== "held" && !terminal) || selection !== null) + if ( + selection !== null || + (resolution.status !== "held" && resolution.status !== "deferred") + ) throw Error(!set ? "incomplete assessment set" : "missing candidate set"); - if (terminal) { - const recommendations: Record = { - clotho: [], - lachesis: [], - atropos: [], - }; - for (const assessment of assessments) - recommendations[assessment.moduleKind] = [ - ...assessment.recommendedOptionKeys, - ]; - if ( - body(resolution.order) !== - body([...policy.orders[snapshot.situation]]) || - body(resolution.recommendations) !== body(recommendations) || - resolution.excluded.length !== 0 || - resolution.abstentions.length !== 0 || - resolution.conflicts.length !== 0 || - resolution.ranking.length !== 0 || - resolution.conceded.length !== 0 - ) - throw Error("incomplete deferred asserts unsupported arbitration"); - } + // A held round keeps its original latitude and declares no policy here; + // resolving one would reject historical bytes on every ledger read. + if (resolution.status === "held") return; + // Revision 2 owns the terminal receipt and only an exhausted budget earns + // it, so a round with remaining budget stays open for its missing work. + const declared = personalPolicyFor( + snapshot.policyId, + snapshot.policyRevision, + ); + if (declared.revision < 2) + throw Error("incomplete deferred requires policy revision 2"); + if (resolution.holdReason !== EVALUATION_BUDGET_EXHAUSTED) + throw Error("incomplete deferred requires exhausted evaluation budget"); + // It cannot be policy-replayed, so it may restate only the declared order + // and what its stored assessments prove. + const recommendations: Record = { + clotho: [], + lachesis: [], + atropos: [], + }; + for (const assessment of assessments) + recommendations[assessment.moduleKind] = [ + ...assessment.recommendedOptionKeys, + ]; + if ( + body(resolution.order) !== + body([...declared.orders[snapshot.situation]]) || + body(resolution.recommendations) !== body(recommendations) || + resolution.excluded.length !== 0 || + resolution.abstentions.length !== 0 || + resolution.conflicts.length !== 0 || + resolution.ranking.length !== 0 || + resolution.conceded.length !== 0 + ) + throw Error("incomplete deferred asserts unsupported arbitration"); return; } + const policy = personalPolicyFor(snapshot.policyId, snapshot.policyRevision); const replayed = resolvePersonalRound({ policy, snapshot, diff --git a/packages/lina-core/test/judgment-candidates.test.ts b/packages/lina-core/test/judgment-candidates.test.ts index 5cb4f9b..a91016b 100644 --- a/packages/lina-core/test/judgment-candidates.test.ts +++ b/packages/lina-core/test/judgment-candidates.test.ts @@ -372,7 +372,7 @@ for (const complete of [false, true]) { ...held(), status: "deferred" }, null, ), - ).toThrow(complete ? "missing candidate set" : "incomplete assessment set"); + ).toThrow("incomplete deferred requires policy revision 2"); const record = held(); store.recordResolution(snapshot.roundId, record, null); expect(() => diff --git a/packages/lina-core/test/judgment-deferred.test.ts b/packages/lina-core/test/judgment-deferred.test.ts index 62202c2..62c992e 100644 --- a/packages/lina-core/test/judgment-deferred.test.ts +++ b/packages/lina-core/test/judgment-deferred.test.ts @@ -42,17 +42,20 @@ function deferredRecord( ranking: [], conceded: [], status: "deferred", - holdReason: "assessment evidence incomplete", + holdReason: EVALUATION_BUDGET_EXHAUSTED, ...patch, }); } -function v2Round(context: ReturnType) { +function v2Round( + context: ReturnType, + policyRevision = PERSONAL_POLICY_V2.revision, +) { const { store, input } = context; const snapshot = parseJudgmentSnapshotRef({ ...input.snapshot, - roundId: "policy-evidence-round-v2", - policyRevision: PERSONAL_POLICY_V2.revision, + roundId: `policy-evidence-round-v${policyRevision}`, + policyRevision, sequence: input.snapshot.sequence + 1, }); store.openRound(snapshot); @@ -127,9 +130,7 @@ test("deferred resolution with a closed candidate set but partial assessments re store.closeCandidateSet(round.candidates); for (const assessment of round.set.assessments.slice(0, 1)) store.putAssessment(assessment); - const record = deferredRecord(round.input, { - holdReason: "module assessments incomplete", - }); + const record = deferredRecord(round.input); store.recordResolution(round.snapshot.roundId, record, null); @@ -144,9 +145,7 @@ test("deferred resolution with no candidates and no assessments records", () => fixtures.push(context); const { store } = context; const round = v2Round(context); - const record = deferredRecord(round.input, { - holdReason: "no candidate evidence", - }); + const record = deferredRecord(round.input); store.recordResolution(round.snapshot.roundId, record, null); expect(store.getRound(round.snapshot.roundId)?.status).toBe("deferred"); @@ -163,7 +162,7 @@ test("a revision-1 incomplete round ends held and never deferred", () => { // Revision 1 declares no terminal receipt, so its rounds stay retryable. expect(() => store.recordResolution(input.snapshot.roundId, deferredRecord(input), null), - ).toThrow("incomplete assessment set"); + ).toThrow("incomplete deferred requires policy revision 2"); expect(store.getRound(input.snapshot.roundId)?.status).toBe("open"); const held = deferredRecord(input, { @@ -175,6 +174,56 @@ test("a revision-1 incomplete round ends held and never deferred", () => { expect(store.getResolution(input.snapshot.roundId)).toEqual(held); }); +test("an incomplete round terminates only on an exhausted evaluation budget", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store } = context; + const round = v2Round(context); + for (const assessment of round.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + // Budget may remain, so an arbitrary reason cannot close the round. + expect(() => + store.recordResolution( + round.snapshot.roundId, + deferredRecord(round.input, { + holdReason: "module assessments incomplete", + }), + null, + ), + ).toThrow("incomplete deferred requires exhausted evaluation budget"); + expect(store.getRound(round.snapshot.roundId)?.status).toBe("open"); + + const held = deferredRecord(round.input, { + status: "held", + holdReason: "module assessments incomplete", + }); + store.recordResolution(round.snapshot.roundId, held, null); + expect(store.getRound(round.snapshot.roundId)?.status).toBe("held"); + expect(store.getResolution(round.snapshot.roundId)).toEqual(held); +}); + +test("a legacy incomplete hold under an undeclared policy stays readable", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture, input } = context; + // Revision 3 is not declared by this binary, as a later catalog would be. + const round = v2Round(context, 3); + for (const assessment of round.set.assessments.slice(0, 2)) + store.putAssessment(assessment); + const held = deferredRecord(round.input, { + status: "held", + holdReason: "historical missing input", + }); + + store.recordResolution(round.snapshot.roundId, held, null); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(round.snapshot.roundId)).toEqual(held); + // One unsupported historical row must not fail unrelated ledger reads. + expect(reopened.getIntention("promise")).not.toBeNull(); + expect(reopened.getRound(input.snapshot.roundId)?.status).toBe("open"); +}); + test("held resolution with a partial assessment set still records", () => { const context = policyEvidenceFixture(); fixtures.push(context); From fc19a3558c73f96ceb193f0261cb302be2a5c7ea Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:16:34 +0900 Subject: [PATCH 07/12] fix(core): report readouts whose bound holds no text A readout whose first 4,000 units are whitespace passed whole-value validation and then failed the same non-empty rule on its clipped prefix, so long prose failed the judgment instead of clipping. Storing the whitespace prefix instead would have produced a record the parser cannot read back, so such a bound is now reported for repair or hold. The contract also records two boundaries the code cannot verify: store writes keep the permissive historical parsers, so fresh output must go through the builders, and an incomplete held receipt carries unreplayed diagnostics that are not execution, learning or ranking evidence. Regression failed first, then passed. lina-core 2065 pass; typecheck, lint and build pass. --- .../016_neural_preference_contract.md | 4 ++-- .../src/agents/judgment-validation.ts | 7 +++++- .../lina-core/test/judgment-alignment.test.ts | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index 53564bc..6d13fa1 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 설명문의 앞 4,000 code unit에 실제 글자가 없으면 읽을 수 없는 기록을 만들지 않고 보완·보류 대상으로 보고한다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. @@ -90,7 +90,7 @@ Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회 세 Assessment가 모두 도착했어도 판단 불가가 남으면 policy revision 2의 결과는 `held`다. 예산까지 끝났다면 Host는 이 결과의 `status`만 `deferred`, `holdReason`만 공통 상수 `EVALUATION_BUDGET_EXHAUSTED`로 바꿔 최초 종료 기록을 저장할 수 있다. 나머지 필드는 정책 재생 결과와 같아야 하고 `SelectionSpec`은 없다. 예산 소진 전환은 revision 2의 규칙이므로 revision 1로 재생한 `held`는 전환하지 않고 재시도 가능한 상태로 남긴다. 이미 저장한 `held` 회차를 수정하거나 재개하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. -증거가 불완전한 회차의 비실행 종료 기록도 policy revision 2의 규칙이다. revision 1 회차는 재시도 가능한 `held`로만 끝나며 새 종료 상태로 닫지 않는다. 종료 기록의 `holdReason`은 `EVALUATION_BUDGET_EXHAUSTED`여야 한다. 예산이 남아 있으면 회차를 열어 두고 보완하며, 다른 사유의 중단은 `held`로 남긴다. 기록을 남기면 그 회차에는 더 이상 평가를 저장할 수 없으므로 남은 보완 기회를 임의로 버리지 않는다. 정책 재생이 불가능하므로 revision 2의 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. `held` 경로는 정책 선언을 조회하지 않는다. 이 binary가 모르는 catalog·revision으로 기록된 과거 행 하나가 저장소의 다른 읽기까지 막지 않아야 한다. +증거가 불완전한 회차의 비실행 종료 기록도 policy revision 2의 규칙이다. revision 1 회차는 재시도 가능한 `held`로만 끝나며 새 종료 상태로 닫지 않는다. 종료 기록의 `holdReason`은 `EVALUATION_BUDGET_EXHAUSTED`여야 한다. 예산이 남아 있으면 회차를 열어 두고 보완하며, 다른 사유의 중단은 `held`로 남긴다. 기록을 남기면 그 회차에는 더 이상 평가를 저장할 수 없으므로 남은 보완 기회를 임의로 버리지 않는다. 정책 재생이 불가능하므로 revision 2의 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. `held` 경로는 정책 선언을 조회하지 않는다. 이 binary가 모르는 catalog·revision으로 기록된 과거 행 하나가 저장소의 다른 읽기까지 막지 않아야 한다. 대신 그 기록의 서술 필드는 정책 재생으로 검증되지 않은 비실행 진단 자료다. 실행·학습·순위 근거로 소비하지 않는다. `AssessmentSet`은 snapshotId·candidateSetHash·objectiveProfileRefs·모듈별 평가 해시·누락 사유를 묶는다. Host는 현재성·필수 조건으로 적격 후보를 확인한다. 모이라이의 종합 기능은 LLM 해석과 `ArbitrationPolicy`를 포함하며, 각자의 추천을 유지한 채 목표 충돌을 조정한다. 종합 LLM이나 Host가 선언된 정책 밖의 임의 우선순위를 적용하지 않는다. diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 2616fde..dd08d94 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -232,8 +232,13 @@ export function prepareReadout( next = value.charCodeAt(end); if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--; + const text = value.slice(0, end); + // A stored readout must say something, so a bound holding only whitespace is + // reported for repair instead of becoming an unreadable record. + if (text.trim().length === 0) + throw Error(`${label} is empty within its bound`); return { - text: boundedText(value.slice(0, end), label, limit), + text: boundedText(text, label, limit), truncation: { originalLength: value.length, limit, diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts index e7c0a41..70dcf86 100644 --- a/packages/lina-core/test/judgment-alignment.test.ts +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -143,3 +143,27 @@ test("fresh assessments reject supplied truncation provenance while legacy parse "fresh assessment must not supply truncation metadata", ); }); + +test("a readout whose bound holds only whitespace is reported, never stored", () => { + const { input } = setup(); + const source = input.set.assessments[0]; + if (!source) throw Error("missing fixture assessment"); + const { inputDigest: _digest, ...raw } = source; + // The whole prose is valid, but its first 4,000 units carry no text. + expect(() => + api.buildAssessment({ + ...raw, + completeText: `${" ".repeat(4000)}answer`, + }), + ).toThrow("complete text is empty within its bound"); + // Leading whitespace with text inside the bound still clips with provenance. + const prepared = api.buildAssessment({ + ...raw, + completeText: ` ${"a".repeat(4100)}`, + }); + expect(prepared.completeText.length).toBe(4000); + expect(prepared.diagnostics["readoutTruncation"]).toMatchObject({ + originalLength: 4102, + limit: 4000, + }); +}); From c6fd6fb6c92dbf004e824580f87c0e979b8fc974 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:21:33 +0900 Subject: [PATCH 08/12] fix(core): judge readout content by rendering, not by trim The emptiness test used trim(), which strips ECMAScript whitespace but leaves invisible format characters, so a bound holding 4,000 zero-width spaces passed and produced a record that renders empty while real text sat just past the bound. Content is now judged with a Unicode test that also excludes control and format characters. Regression covers space, newline, U+200B and U+FEFF, and a positive case keeps clipping when a zero-width character precedes real text inside the bound. lina-core 2065 pass; typecheck, lint and build pass. --- .../platform/016_neural_preference_contract.md | 2 +- .../src/agents/judgment-validation.ts | 10 +++++++--- .../lina-core/test/judgment-alignment.test.ts | 18 ++++++++++-------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index 6d13fa1..9d93b4b 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 설명문의 앞 4,000 code unit에 실제 글자가 없으면 읽을 수 없는 기록을 만들지 않고 보완·보류 대상으로 보고한다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 설명문의 앞 4,000 code unit에 읽을 수 있는 글자가 없으면 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index dd08d94..0e45aad 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -217,6 +217,10 @@ function mechanismRevision(value: unknown): MechanismRevision { return revision(value, "mechanism revision"); } +/** Whitespace, control and format characters render as nothing, so a readout + * made only of them carries no content even though it has length. */ +const READOUT_CONTENT = /[^\s\p{Cc}\p{Cf}]/u; + /** Only fresh output preparation clips text; persisted record parsers never do. */ export function prepareReadout( value: string, @@ -233,9 +237,9 @@ export function prepareReadout( if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--; const text = value.slice(0, end); - // A stored readout must say something, so a bound holding only whitespace is - // reported for repair instead of becoming an unreadable record. - if (text.trim().length === 0) + // A stored readout must say something, so a bound holding nothing readable is + // reported for repair instead of becoming an empty-looking record. + if (!READOUT_CONTENT.test(text)) throw Error(`${label} is empty within its bound`); return { text: boundedText(text, label, limit), diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts index 70dcf86..f9c1c56 100644 --- a/packages/lina-core/test/judgment-alignment.test.ts +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -149,17 +149,19 @@ test("a readout whose bound holds only whitespace is reported, never stored", () const source = input.set.assessments[0]; if (!source) throw Error("missing fixture assessment"); const { inputDigest: _digest, ...raw } = source; - // The whole prose is valid, but its first 4,000 units carry no text. - expect(() => - api.buildAssessment({ - ...raw, - completeText: `${" ".repeat(4000)}answer`, - }), - ).toThrow("complete text is empty within its bound"); + // The whole prose is valid, but its first 4,000 units carry no readable text. + // Zero-width and other format characters render as nothing, like whitespace. + for (const blank of [" ", "\n", "\u200b", "\ufeff"]) + expect(() => + api.buildAssessment({ + ...raw, + completeText: `${blank.repeat(4000)}answer`, + }), + ).toThrow("complete text is empty within its bound"); // Leading whitespace with text inside the bound still clips with provenance. const prepared = api.buildAssessment({ ...raw, - completeText: ` ${"a".repeat(4100)}`, + completeText: `\u200b ${"a".repeat(4100)}`, }); expect(prepared.completeText.length).toBe(4000); expect(prepared.diagnostics["readoutTruncation"]).toMatchObject({ From 3f640f19f02983c6d97576651691000190710542 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:26:58 +0900 Subject: [PATCH 09/12] fix(core): apply the readable-text rule to short readouts The content test ran only after clipping, so the early return for prose within the bound still accepted a readout made only of control or format characters: a single zero-width space produced an assessment whose text renders empty, while the same content past the bound was rejected. The bounded text is now determined first and the rule applies to both return paths. Regressions cover short and long invisible prose for assessments and for both dialogue readouts. lina-core 2065 pass; typecheck, lint and build pass. --- .../016_neural_preference_contract.md | 2 +- .../src/agents/judgment-validation.ts | 21 ++++++++++--------- .../lina-core/test/judgment-alignment.test.ts | 13 ++++++++---- .../test/judgment-dialogue-contract.test.ts | 11 ++++++++++ 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index 9d93b4b..b3aa297 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 설명문의 앞 4,000 code unit에 읽을 수 있는 글자가 없으면 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 상한 안의 설명문에 읽을 수 있는 글자가 없으면 길이와 무관하게 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 0e45aad..f311732 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -230,17 +230,18 @@ export function prepareReadout( boundedText(value, label, Number.MAX_SAFE_INTEGER); if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_READOUT_TEXT) throw Error("invalid readout limit"); - if (value.length <= limit) return { text: value, truncation: null }; let end = limit; - const last = value.charCodeAt(end - 1), - next = value.charCodeAt(end); - if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) - end--; - const text = value.slice(0, end); - // A stored readout must say something, so a bound holding nothing readable is - // reported for repair instead of becoming an empty-looking record. - if (!READOUT_CONTENT.test(text)) - throw Error(`${label} is empty within its bound`); + if (value.length > limit) { + const last = value.charCodeAt(end - 1), + next = value.charCodeAt(end); + if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) + end--; + } + const text = value.length <= limit ? value : value.slice(0, end); + // A stored readout must say something, so prose that renders empty within its + // bound is reported for repair instead of becoming an empty-looking record. + if (!READOUT_CONTENT.test(text)) throw Error(`${label} has no readable text`); + if (value.length <= limit) return { text, truncation: null }; return { text: boundedText(text, label, limit), truncation: { diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts index f9c1c56..71e2e16 100644 --- a/packages/lina-core/test/judgment-alignment.test.ts +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -144,20 +144,25 @@ test("fresh assessments reject supplied truncation provenance while legacy parse ); }); -test("a readout whose bound holds only whitespace is reported, never stored", () => { +test("a readout with no readable text is reported, never stored", () => { const { input } = setup(); const source = input.set.assessments[0]; if (!source) throw Error("missing fixture assessment"); const { inputDigest: _digest, ...raw } = source; - // The whole prose is valid, but its first 4,000 units carry no readable text. // Zero-width and other format characters render as nothing, like whitespace. - for (const blank of [" ", "\n", "\u200b", "\ufeff"]) + for (const blank of [" ", "\n", "\u200b", "\ufeff"]) { + // Long prose whose first 4,000 units carry no readable text. expect(() => api.buildAssessment({ ...raw, completeText: `${blank.repeat(4000)}answer`, }), - ).toThrow("complete text is empty within its bound"); + ).toThrow("complete text has no readable text"); + // A short readout is bounded by the same rule. + expect(() => + api.buildAssessment({ ...raw, completeText: blank.repeat(3) }), + ).toThrow(); + } // Leading whitespace with text inside the bound still clips with provenance. const prepared = api.buildAssessment({ ...raw, diff --git a/packages/lina-core/test/judgment-dialogue-contract.test.ts b/packages/lina-core/test/judgment-dialogue-contract.test.ts index 8119da4..0506e44 100644 --- a/packages/lina-core/test/judgment-dialogue-contract.test.ts +++ b/packages/lina-core/test/judgment-dialogue-contract.test.ts @@ -133,6 +133,17 @@ function db() { test("fresh dialogue bounds readouts before hashing and retains clipping provenance on reopen", () => { seed(); const original = record(); + // A readout that renders empty is reported whether or not it reaches the bound. + for (const field of ["synthesis", "rationale"] as const) + for (const text of ["\u200b", "\u200b".repeat(4100)]) + expect(() => + api.buildDialogueResolution({ + ...original, + [field]: text, + snapshot, + assessments, + }), + ).toThrow("no readable text"); const long = { ...original, synthesis: "s".repeat(5000), From 14279945044c55b23d8edadcc8dcbc1d93131df3 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:35:39 +0900 Subject: [PATCH 10/12] fix(core): preserve stored receipts this build cannot replay The incomplete-deferred path resolved a policy before its field checks, so a receipt written by a later build under an undeclared revision would fail the ledger audit that runs inside every transaction, leaving unrelated rounds and intentions unreadable after a downgrade. Validation now takes an explicit stored flag: auditing persisted bytes keeps such a receipt as history, while a fresh write still requires a policy it can check. The complete-evidence replay path keeps its existing behavior, tracked separately. The readable-text class also accepted standalone combining marks, which render as nothing without a base character, so it now excludes Mn and Me. Both regressions failed first, then passed. lina-core 2066 pass; typecheck, lint and build pass. --- .../016_neural_preference_contract.md | 4 +-- .../lina-core/src/agents/judgment-policy.ts | 15 ++++++-- .../lina-core/src/agents/judgment-store.ts | 17 ++++++++-- .../src/agents/judgment-validation.ts | 7 ++-- .../lina-core/test/judgment-alignment.test.ts | 7 +++- .../lina-core/test/judgment-deferred.test.ts | 34 +++++++++++++++++++ 6 files changed, 73 insertions(+), 11 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index b3aa297..423ecc0 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 상한 안의 설명문에 읽을 수 있는 글자가 없으면 길이와 무관하게 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 상한 안의 설명문에 읽을 수 있는 글자가 없으면 길이와 무관하게 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자와 기반 문자가 없는 결합 표시도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. @@ -90,7 +90,7 @@ Host는 `candidateLimit`, `maxEvaluationGenerations`, `maxAdditionalCalls`, 회 세 Assessment가 모두 도착했어도 판단 불가가 남으면 policy revision 2의 결과는 `held`다. 예산까지 끝났다면 Host는 이 결과의 `status`만 `deferred`, `holdReason`만 공통 상수 `EVALUATION_BUDGET_EXHAUSTED`로 바꿔 최초 종료 기록을 저장할 수 있다. 나머지 필드는 정책 재생 결과와 같아야 하고 `SelectionSpec`은 없다. 예산 소진 전환은 revision 2의 규칙이므로 revision 1로 재생한 `held`는 전환하지 않고 재시도 가능한 상태로 남긴다. 이미 저장한 `held` 회차를 수정하거나 재개하지 않는다. 이미 선택한 뒤의 실패라면 기존 `held` 결정을 유지하거나 취소한다. -증거가 불완전한 회차의 비실행 종료 기록도 policy revision 2의 규칙이다. revision 1 회차는 재시도 가능한 `held`로만 끝나며 새 종료 상태로 닫지 않는다. 종료 기록의 `holdReason`은 `EVALUATION_BUDGET_EXHAUSTED`여야 한다. 예산이 남아 있으면 회차를 열어 두고 보완하며, 다른 사유의 중단은 `held`로 남긴다. 기록을 남기면 그 회차에는 더 이상 평가를 저장할 수 없으므로 남은 보완 기회를 임의로 버리지 않는다. 정책 재생이 불가능하므로 revision 2의 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. `held` 경로는 정책 선언을 조회하지 않는다. 이 binary가 모르는 catalog·revision으로 기록된 과거 행 하나가 저장소의 다른 읽기까지 막지 않아야 한다. 대신 그 기록의 서술 필드는 정책 재생으로 검증되지 않은 비실행 진단 자료다. 실행·학습·순위 근거로 소비하지 않는다. +증거가 불완전한 회차의 비실행 종료 기록도 policy revision 2의 규칙이다. revision 1 회차는 재시도 가능한 `held`로만 끝나며 새 종료 상태로 닫지 않는다. 종료 기록의 `holdReason`은 `EVALUATION_BUDGET_EXHAUSTED`여야 한다. 예산이 남아 있으면 회차를 열어 두고 보완하며, 다른 사유의 중단은 `held`로 남긴다. 기록을 남기면 그 회차에는 더 이상 평가를 저장할 수 없으므로 남은 보완 기회를 임의로 버리지 않는다. 정책 재생이 불가능하므로 revision 2의 `deferred` 기록은 스스로 증명할 수 있는 값만 담는다. 판단 순서는 선언된 정책의 해당 상황 순서와 같아야 하고, 모듈별 추천은 실제 저장된 Assessment의 `recommendedOptionKeys`와 같아야 하며 평가가 없는 모듈은 비어 있어야 한다. 제외·기권·충돌·순위·양보는 후보 집합과 완결된 평가에서만 나오므로 비운다. 기존 `held` 기록의 서술 필드 범위는 PR #14 계약을 유지하며, 과거 바이트·해시를 보존하기 위해 별도 버전 규칙 없이 좁히지 않는다. `held` 경로는 정책 선언을 조회하지 않는다. 이 binary가 모르는 catalog·revision으로 기록된 과거 행 하나가 저장소의 다른 읽기까지 막지 않아야 한다. 대신 그 기록의 서술 필드는 정책 재생으로 검증되지 않은 비실행 진단 자료다. 실행·학습·순위 근거로 소비하지 않는다. 이후 버전이 쓴 `deferred` 기록도 같다. 저장된 바이트를 감사할 때는 모르는 정책의 기록을 과거 자료로 보존하고, 새로 쓸 때는 정책을 확인할 수 없으면 거부한다. 완결된 증거의 회차는 여전히 재생으로 검증하며 그 경로의 downgrade 읽기는 별도 후속 작업이다. `AssessmentSet`은 snapshotId·candidateSetHash·objectiveProfileRefs·모듈별 평가 해시·누락 사유를 묶는다. Host는 현재성·필수 조건으로 적격 후보를 확인한다. 모이라이의 종합 기능은 LLM 해석과 `ArbitrationPolicy`를 포함하며, 각자의 추천을 유지한 채 목표 충돌을 조정한다. 종합 LLM이나 Host가 선언된 정책 밖의 임의 우선순위를 적용하지 않는다. diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index 2aaf261..917ab35 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -58,13 +58,22 @@ export const PERSONAL_POLICY_V2: ArbitrationPolicy = Object.freeze({ }); export const PERSONAL_POLICY_CURRENT = PERSONAL_POLICY_V2; +/** A later build may declare revisions this one cannot replay. */ +export function personalPolicyIfDeclared( + policyId: string, + revision: number, +): ArbitrationPolicy | null { + return ( + [PERSONAL_POLICY_V1, PERSONAL_POLICY_V2].find( + (p) => p.policyId === policyId && p.revision === revision, + ) ?? null + ); +} export function personalPolicyFor( policyId: string, revision: number, ): ArbitrationPolicy { - const policy = [PERSONAL_POLICY_V1, PERSONAL_POLICY_V2].find( - (p) => p.policyId === policyId && p.revision === revision, - ); + const policy = personalPolicyIfDeclared(policyId, revision); if (!policy) throw Error("unsupported personal policy declaration"); return policy; } diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 25ccaa3..db5b72c 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -27,7 +27,11 @@ import { validateDialogueResolution, } from "./judgment-dialogue.ts"; import { validateCandidateEvidence } from "./judgment-evidence.ts"; -import { personalPolicyFor, resolvePersonalRound } from "./judgment-policy.ts"; +import { + personalPolicyFor, + personalPolicyIfDeclared, + resolvePersonalRound, +} from "./judgment-policy.ts"; import { initializeJudgmentSchema } from "./judgment-schema.ts"; import { canonicalJson, @@ -67,6 +71,7 @@ function validateResolutionBinding( candidates: CandidateSet | null, lookupIntention: (id: string) => IntentionRecord | null, assessments: Assessment[], + stored: boolean, ): void { if (resolution.schemaVersion === 2) { if (selection !== null || candidates !== null) @@ -87,10 +92,16 @@ function validateResolutionBinding( if (resolution.status === "held") return; // Revision 2 owns the terminal receipt and only an exhausted budget earns // it, so a round with remaining budget stays open for its missing work. - const declared = personalPolicyFor( + const declared = personalPolicyIfDeclared( snapshot.policyId, snapshot.policyRevision, ); + // A later build may have written this receipt. Auditing persisted bytes + // preserves it as history; a fresh write still needs a policy to check. + if (!declared) { + if (stored) return; + throw Error("unsupported personal policy declaration"); + } if (declared.revision < 2) throw Error("incomplete deferred requires policy revision 2"); if (resolution.holdReason !== EVALUATION_BUDGET_EXHAUSTED) @@ -610,6 +621,7 @@ export class JudgmentStore { candidates, (id) => this.getIntention(id), assessments, + false, ); const now = this.now(); this.db @@ -1142,6 +1154,7 @@ export class JudgmentStore { candidatesByRound.get(record.roundId) ?? null, (id) => intentionsById.get(id) ?? null, assessments, + true, ); } if (this.db.prepare("PRAGMA foreign_key_check").all().length > 0) diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index f311732..5274d6c 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -217,9 +217,10 @@ function mechanismRevision(value: unknown): MechanismRevision { return revision(value, "mechanism revision"); } -/** Whitespace, control and format characters render as nothing, so a readout - * made only of them carries no content even though it has length. */ -const READOUT_CONTENT = /[^\s\p{Cc}\p{Cf}]/u; +/** Whitespace, control and format characters render as nothing, and a combining + * mark needs a base character, so a readout made only of them carries no content + * even though it has length. */ +const READOUT_CONTENT = /[^\s\p{Cc}\p{Cf}\p{Mn}\p{Me}]/u; /** Only fresh output preparation clips text; persisted record parsers never do. */ export function prepareReadout( diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts index 71e2e16..57ac647 100644 --- a/packages/lina-core/test/judgment-alignment.test.ts +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -150,7 +150,8 @@ test("a readout with no readable text is reported, never stored", () => { if (!source) throw Error("missing fixture assessment"); const { inputDigest: _digest, ...raw } = source; // Zero-width and other format characters render as nothing, like whitespace. - for (const blank of [" ", "\n", "\u200b", "\ufeff"]) { + // Standalone combining marks render as nothing without a base character. + for (const blank of [" ", "\n", "\u200b", "\ufeff", "\ufe0f", "\u034f"]) { // Long prose whose first 4,000 units carry no readable text. expect(() => api.buildAssessment({ @@ -173,4 +174,8 @@ test("a readout with no readable text is reported, never stored", () => { originalLength: 4102, limit: 4000, }); + // A mark on a base character is content, so composed text stays valid. + expect( + api.buildAssessment({ ...raw, completeText: "e\u0301" }).completeText, + ).toBe("e\u0301"); }); diff --git a/packages/lina-core/test/judgment-deferred.test.ts b/packages/lina-core/test/judgment-deferred.test.ts index 62c992e..0ac84c5 100644 --- a/packages/lina-core/test/judgment-deferred.test.ts +++ b/packages/lina-core/test/judgment-deferred.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { DatabaseSync } from "node:sqlite"; import type { ResolutionRecord } from "../src/agents/judgment.ts"; import { buildCandidateSet } from "../src/agents/judgment-candidates.ts"; import { @@ -11,6 +12,7 @@ import { } from "../src/agents/judgment-store.ts"; import { assessmentInputDigest, + judgmentDigest, parseAssessment, parseAssessmentSet, parseJudgmentSnapshotRef, @@ -224,6 +226,38 @@ test("a legacy incomplete hold under an undeclared policy stays readable", () => expect(reopened.getRound(input.snapshot.roundId)?.status).toBe("open"); }); +test("a stored deferred receipt from a newer policy stays readable", () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { store, path, fixture, input } = context; + const round = v2Round(context); + const record = deferredRecord(round.input); + store.recordResolution(round.snapshot.roundId, record, null); + // Simulate the same round written by a build that declares revision 3. + const future = parseJudgmentSnapshotRef({ + ...round.snapshot, + policyRevision: 3, + }); + const futureRecord = parseResolutionRecord({ ...record, policyRevision: 3 }); + const db = fixture.keep(new DatabaseSync(path)); + db.prepare( + "UPDATE rounds SET snapshot = ?, snapshot_digest = ? WHERE round_id = ?", + ).run(JSON.stringify(future), snapshotDigest(future), round.snapshot.roundId); + db.prepare( + "UPDATE resolution_records SET body = ?, digest = ? WHERE round_id = ?", + ).run( + JSON.stringify(futureRecord), + judgmentDigest(futureRecord), + round.snapshot.roundId, + ); + + const reopened = fixture.keep(new JudgmentStore(path)); + expect(reopened.getResolution(round.snapshot.roundId)).toEqual(futureRecord); + // One row this binary cannot replay must not fail unrelated ledger reads. + expect(reopened.getIntention("promise")).not.toBeNull(); + expect(reopened.getRound(input.snapshot.roundId)?.status).toBe("open"); +}); + test("held resolution with a partial assessment set still records", () => { const context = policyEvidenceFixture(); fixtures.push(context); From daf221322e738a12d3342e170a1a85ffbb7b238a Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:41:18 +0900 Subject: [PATCH 11/12] fix(core): hash readout provenance over UTF-16 code units The provenance digest hashed the readout as UTF-8, which folds every unpaired surrogate onto the same replacement character, so two originals differing only in a trailing lone surrogate could share one sourceDigest and the metadata could no longer identify which prose was clipped. The digest now hashes UTF-16LE bytes, which is injective over code units. No stored digest changes meaning, since F1 has no product writer yet and this branch is unmerged. Regression failed first, then passed. lina-core 2067 pass; typecheck, lint and build pass. --- .../platform/016_neural_preference_contract.md | 2 +- .../lina-core/src/agents/judgment-validation.ts | 4 +++- .../lina-core/test/judgment-alignment.test.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index 423ecc0..b0e452c 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 상한 안의 설명문에 읽을 수 있는 글자가 없으면 길이와 무관하게 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자와 기반 문자가 없는 결합 표시도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 해시는 UTF-16LE 바이트로 계산한다. UTF-8은 짝 없는 surrogate를 모두 같은 대체 문자로 바꿔 서로 다른 원문이 같은 해시를 갖는다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 상한 안의 설명문에 읽을 수 있는 글자가 없으면 길이와 무관하게 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자와 기반 문자가 없는 결합 표시도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 5274d6c..2c4edf5 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -248,7 +248,9 @@ export function prepareReadout( truncation: { originalLength: value.length, limit, - sourceDigest: createHash("sha256").update(value).digest("hex"), + // UTF-16LE keeps the digest injective over code units, which UTF-8 is + // not: it folds every unpaired surrogate onto one replacement. + sourceDigest: createHash("sha256").update(value, "utf16le").digest("hex"), }, }; } diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts index 57ac647..52fb033 100644 --- a/packages/lina-core/test/judgment-alignment.test.ts +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -179,3 +179,20 @@ test("a readout with no readable text is reported, never stored", () => { api.buildAssessment({ ...raw, completeText: "e\u0301" }).completeText, ).toBe("e\u0301"); }); + +test("readout provenance distinguishes originals with ill-formed UTF-16", () => { + const { input } = setup(); + const source = input.set.assessments[0]; + if (!source) throw Error("missing fixture assessment"); + const { inputDigest: _digest, ...raw } = source; + // Unpaired surrogates are distinct code units, so their digests must differ. + const [first, second] = ["\ud800", "\ud801"].map((tail) => { + const prepared = api.buildAssessment({ + ...raw, + completeText: `${"a".repeat(4000)}${tail}`, + }); + expect(prepared.completeText).toBe("a".repeat(4000)); + return prepared.diagnostics["readoutTruncation"]; + }); + expect(first).not.toEqual(second); +}); From 7ded8af6c413296ee6c8094f107c448b31f733c5 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:59:42 +0900 Subject: [PATCH 12/12] fix(core): treat default-ignorable characters as unreadable Hangul fillers such as U+3164 and U+115F are category Lo, so the readable-text class still accepted a readout made only of them, which renders blank. The class now excludes Default_Ignorable_Code_Point, the Unicode property the earlier category-by-category fixes were approximating. Regression adds both fillers to the existing blank cases. lina-core 2067 pass; typecheck, lint and build pass. --- .../platform/016_neural_preference_contract.md | 2 +- .../lina-core/src/agents/judgment-validation.ts | 9 +++++---- packages/lina-core/test/judgment-alignment.test.ts | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/plans/platform/016_neural_preference_contract.md b/docs/plans/platform/016_neural_preference_contract.md index b0e452c..cdfccfc 100644 --- a/docs/plans/platform/016_neural_preference_contract.md +++ b/docs/plans/platform/016_neural_preference_contract.md @@ -70,7 +70,7 @@ Assessment = { schemaVersion, moduleKind, snapshotId, inputDigest, `workingRevision`은 현재 문맥의 revision이고 `instructionRevision`은 원본 request·현재 지시의 revision이다. 서로 대신하지 않는다. 도메인별 공개된 읽기 결과와 원본 참조를 조립하고 읽기 전후 버전·확정 직전 현재성을 확인한다. 여러 DB를 원자적으로 읽는다고 가정하지 않는다. -위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 해시는 UTF-16LE 바이트로 계산한다. UTF-8은 짝 없는 surrogate를 모두 같은 대체 문자로 바꿔 서로 다른 원문이 같은 해시를 갖는다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 상한 안의 설명문에 읽을 수 있는 글자가 없으면 길이와 무관하게 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어·형식 문자와 기반 문자가 없는 결합 표시도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. +위 표기는 필수 영역을 나타내며 `forecasts | values | continuity`는 moduleKind에 따른 구분 타입이다. `completeText`는 생성된 모듈 의견의 제한된 읽기 결과다(D23). 새 `buildAssessment`는 설명문이 4,000 UTF-16 code unit을 넘으면 surrogate pair를 보존하며 자르고 `diagnostics.readoutTruncation`에 원래 길이·상한·원문 SHA-256을 남긴다. 해시는 UTF-16LE 바이트로 계산한다. UTF-8은 짝 없는 surrogate를 모두 같은 대체 문자로 바꿔 서로 다른 원문이 같은 해시를 갖는다. 이 상한은 토큰 수가 아니다. 저장된 레코드의 파서는 절대로 자르거나 해시를 다시 쓰지 않는다. 잘린 원문은 보관하지 않으므로 이 해시는 생성 시점의 주장된 출처이며 파서가 대조할 수 있는 검증된 내용이 아니다. 원문 대조가 필요한 소비자는 생성 단계에서 원문을 따로 보존해야 한다. 저장 경계도 이 값을 검증하지 못한다. `putAssessment`와 대화 기록은 과거 바이트를 읽어야 하므로 관대한 파서를 쓰고, 형식만 확인한다. 그래서 새 판단을 만드는 제품 경로는 반드시 `buildAssessment`와 `buildDialogueResolution`을 거치고 잘림 정보를 직접 조립하지 않는다. 이 요구는 F2 생성 경로에서 검증한다. 상한 안의 설명문에 읽을 수 있는 글자가 없으면 길이와 무관하게 빈 기록을 만들지 않고 보완·보류 대상으로 보고한다. 공백뿐 아니라 제어 문자, 기반 문자가 없는 결합 표시, 한글 채움 문자 같은 무시 가능 문자도 내용으로 세지 않는다. 구조화된 결과를 만들 수 없으면 텍스트만으로 정상 판단을 대신하지 않는다. 모델이 주장한 계산 결과·참조는 Host가 실제 도구/계산 receipt와 대조한다. 모델·세션 ID는 진단 자료이며 판단의 권위나 별도 인격이 아니다. 클로토의 `projectConsequences`는 해당 개인에게 공개된 도메인 상태와 선언된 규칙만 사용하는 제안 조회 포트다. 실제 World 진행이나 비공개 상태의 정답 복사를 예측으로 사용하지 않는다. `forecastId`, `optionKey`, 관측 항목·시점과 `predictionMethodRevision`을 실제 결과에 연결한다. diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 2c4edf5..13b631d 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -217,10 +217,11 @@ function mechanismRevision(value: unknown): MechanismRevision { return revision(value, "mechanism revision"); } -/** Whitespace, control and format characters render as nothing, and a combining - * mark needs a base character, so a readout made only of them carries no content - * even though it has length. */ -const READOUT_CONTENT = /[^\s\p{Cc}\p{Cf}\p{Mn}\p{Me}]/u; +/** Whitespace, control and default-ignorable characters render as nothing, and a + * combining mark needs a base character, so a readout made only of them carries + * no content even though it has length. */ +const READOUT_CONTENT = + /[^\s\p{Cc}\p{Cf}\p{Mn}\p{Me}\p{Default_Ignorable_Code_Point}]/u; /** Only fresh output preparation clips text; persisted record parsers never do. */ export function prepareReadout( diff --git a/packages/lina-core/test/judgment-alignment.test.ts b/packages/lina-core/test/judgment-alignment.test.ts index 52fb033..caddbce 100644 --- a/packages/lina-core/test/judgment-alignment.test.ts +++ b/packages/lina-core/test/judgment-alignment.test.ts @@ -150,8 +150,18 @@ test("a readout with no readable text is reported, never stored", () => { if (!source) throw Error("missing fixture assessment"); const { inputDigest: _digest, ...raw } = source; // Zero-width and other format characters render as nothing, like whitespace. - // Standalone combining marks render as nothing without a base character. - for (const blank of [" ", "\n", "\u200b", "\ufeff", "\ufe0f", "\u034f"]) { + // So do standalone combining marks and default-ignorable letters such as the + // Hangul fillers. + for (const blank of [ + " ", + "\n", + "\u200b", + "\ufeff", + "\ufe0f", + "\u034f", + "\u3164", + "\u115f", + ]) { // Long prose whose first 4,000 units carry no readable text. expect(() => api.buildAssessment({