Skip to content

Commit a0109b5

Browse files
committed
feat: 학습 증거 시간 이상 보류 규칙 구현
상황: - 04 cutover와 rollback 경계는 있었지만 delayed mastery가 evidence 시각 하나만 사용해 clock jump와 offline batch receipt를 구분하지 못했다. - 실제 C0/C1 설치 release 왕복과 독립 검수는 아직 없으므로 packet 종료는 금지된다. 변경: - Python·TypeScript에 EvidenceTime과 ClockAnomaly 동형 모델을 추가하고 mastery 정책의 5분 skew·경과 불일치 허용치를 계약으로 고정했다. - retrieval은 evidenceTime과 appendReceiptAt 경과가 모두 7~14일 window를 만족할 때만 승격하도록 바꿨다. - 역행·future skew·경과 불일치 credit은 deferredCreditEventIds에 보존하고 outcome을 reviewDue로 돌려 새 retrieval을 요구한다. - canonical writer가 최초 append receipt를 별도로 찍고 archive import는 원래 시각을 보존하며 queue는 두 시각 중 늦은 때부터 지연을 계산한다. - clock·offline import 회귀와 evidence-migration 통합 감사를 확장하고 계약 문서·R10 사실 묶음을 갱신했다. 영향: - backdated import나 비정상 clock만으로 transfer가 mastered로 승격되지 않는다. - 보류된 증거는 삭제되지 않아 원인을 감사할 수 있고 정상 시각의 새 retrieval로 복구할 수 있다. - 실제 설치 release 왕복과 독립 사람 증거가 남아 04 TODO 폴더는 유지된다. 검증: - canonical mastery 및 archive 회귀: 40 passed - clock·offline time policy: 3 passed - Python/TypeScript anomaly vector 동형성 통과 - TypeScript production build 및 compile 통과 - evidence migration Chromium·Local·launcher 통합 감사 통과 - ruff 및 product contract freshness 통과 - root-clean·docs·backend preflight: 3/3, backend 1607 passed - plan-quality 통과
1 parent ecbd0fe commit a0109b5

28 files changed

Lines changed: 692 additions & 130 deletions

File tree

contracts/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
| `learningArchive.schema.json` | document·draft·virtual FS·package·evidence 실제 bytes와 lineage를 묶는 닫힌 archive v2 계약 | Web export와 Local atomic import |
2222
| `publicLearningCatalog.json` | 472개 canonical LessonRef의 browser/local tier, eligible path, strong CheckSpec 공개 계약 | Landing lesson generator와 공개 route |
2323
| `checkSandboxBroker.schema.json` | Local 검사 요청·응답, nonce, HMAC, frame 제한을 고정한 닫힌 broker 계약 | Python client와 Windows launcher AppContainer broker |
24+
| `learningEvent.schema.json` | canonical 학습 event와 `evidenceTime`·첫 append receipt 계약 | Python/TypeScript event validator와 evidence archive |
25+
| `masteryPolicy.v1.json` | 단계 전이, retrieval window, clock anomaly 허용 오차 계약 | Python reference reducer와 TypeScript 동형 reducer |
2426
| `runRouteState.schema.json` | 공개 레슨, Web Run, Local 사이의 lesson identity, path, runtime, durable history 계약 | Landing handoff와 공용 editor route adapter |
2527
| `webCompatibilityC0.json` | 기존 `/codaro/app/` 제품 tree의 source·build·hash·response type 고정 계약 | Pages C0 build, C1 조립, deployed crawl |
2628

@@ -30,4 +32,6 @@
3032

3133
`publicLearningCatalog.json`은 이동 가능한 `mainPlan/` 트리를 production build 입력으로 사용하지 않기 위한 안정 계약이다. `uv run python -X utf8 docs/skills/ops/tools/buildPublicLearningCatalog.py`가 현재 curriculum identity와 1:1인지 검사하며, ledger 승인 결과를 반영할 때만 `--write`로 갱신한다.
3234

35+
`CreditGranted.evidenceTime`은 실행 증거가 관측한 시각이고 `appendReceiptAt`은 canonical writer가 처음 수락한 시각이다. import는 두 값을 다시 쓰지 않는다. delayed retrieval은 두 시간축이 모두 window 안에 있고 경과 차이가 `masteryPolicy.v1.json`의 허용 오차 안일 때만 credit을 받는다. 역행·future skew·경과 불일치는 `ClockAnomaly`로 projection에 남고 해당 credit은 보류된다.
36+
3337
`webCompatibilityC0.json``/codaro/app/`의 source commit, LF build 조건, deployed tree hash와 response type을 고정한다. Pages는 이 commit만 별도 checkout해 `/app/`을 만들고 `verifyWebCompatibilityC0.py`가 632개 파일의 exact tree identity와 배포 crawl을 검사한다. 정식 release archive URL·SHA-256은 명시적 release가 실행될 때만 채운다.

contracts/artifactOwners.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ artifacts:
9999
surfacePaths:
100100
- src/codaro/generatedContracts/masteryPolicy.v1.json
101101
- editor/src/lib/generatedContracts/masteryPolicy.v1.json
102+
- src/codaro/curriculum/evidenceTime.py
102103
- src/codaro/curriculum/masteryPolicy.py
104+
- editor/src/lib/evidenceTime.ts
103105
- editor/src/lib/masteryPolicy.ts
104106
- artifactId: prd-evaluation-rubric
105107
role: source

contracts/learningEvent.schema.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,16 @@
171171
"supportEventIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
172172
"attemptFingerprint": { "$ref": "#/$defs/Hash" },
173173
"creditSlices": { "type": "array", "items": { "$ref": "#/$defs/CreditSlice" }, "minItems": 1 },
174-
"evidenceTime": { "type": "string", "format": "date-time" },
175-
"appendReceiptAt": { "type": "string", "format": "date-time" }
174+
"evidenceTime": {
175+
"type": "string",
176+
"format": "date-time",
177+
"description": "Time captured by the execution evidence producer; never rewritten during import."
178+
},
179+
"appendReceiptAt": {
180+
"type": "string",
181+
"format": "date-time",
182+
"description": "Time the canonical writer first accepted the credit; delayed eligibility requires both timelines."
183+
}
176184
}
177185
}
178186
]

contracts/masteryPolicy.v1.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
"independentMaxHintLevel": 1,
2727
"higherStageMaxHintLevel": 0,
2828
"minimumDistinctTaskVariantsForMastered": 3,
29+
"clockPolicy": {
30+
"maximumFutureSkewSeconds": 300,
31+
"maximumElapsedDivergenceSeconds": 300
32+
},
2933
"retrievalWindowDays": {
3034
"minimum": 7,
3135
"maximum": 14

docs/skills/architecture/learning-yaml-contract.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ fixture hash는 key를 정렬한 compact JSON UTF-8 bytes의 SHA-256 SRI다. aut
113113
- transfer 또는 retrieval strong event가 저장되면 같은 check ID의 due 카드는 queue에서 빠진다.
114114
- variant 배열이 존재하거나 ID가 생성됐다는 사실은 학습 evidence가 아니다. strong executor 실제 통과와 append-only event가 있어야 실행 증거로 계산한다.
115115
- 현재 machine audit의 source 저작 범위는 strong CheckSpec 1,419개/468레슨이며 mastery·transfer·24시간 retrieval은 각각 468레슨이다. 1,402개 assessment solution은 1,400개 behavior와 2개 output 검증으로 실행됐고 실패는 0이다. 이것은 author source 검산이며 제품 strong evidence 지원 범위와 다르다. Web behavior는 `localRequired`, Local native behavior는 provisional practice이고 둘 다 strong event 0이다. Web Day 1 output strong event와 legacy migration event 2건은 Local import·재내보내기·Web reload 뒤에도 Web runtime identity를 유지한다. AppContainer broker source와 현재 Windows 11 직접 경계 검증은 존재하지만 전부의 `independentReview`는 pending이고 승인 수는 0이며 cold package 준비, 목표 Windows 10 설치본 conformance, 실제 설치본 round trip과 독립 author review가 없으므로 전체 scheduler 또는 mastery 완료로 부르지 않는다.
116+
- delayed retrieval은 `occurredAt` 표시 시각만으로 열지 않는다. canonical `CreditGranted`의 `evidenceTime`과 최초 `appendReceiptAt`이 모두 최소·최대 window를 만족해야 하며, 둘의 경과 차이가 5분을 넘거나 어느 시간축이 역행하면 `ClockAnomaly`를 기록하고 credit을 `deferredCreditEventIds`에 둔다. 이때 outcome은 `reviewDue`로 남아 새 retrieval을 요구한다. archive import는 원래 두 시각을 보존하고 `MigrationImported.occurredAt`만으로 projection clock을 전진시키지 않는다.
116117

117118
## 렌더링 원칙
118119

editor/src/lib/canonicalLearningEvidence.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,15 @@ export async function buildCanonicalStrongCheckEvents(
9898
}
9999
if (!outcomeIds.length) return events;
100100

101-
const projection = await new MasteryPolicy().reduce(prior, { asOf: evidence.occurredAt });
101+
const appendReceiptAt = new Date().toISOString();
102+
const projection = await new MasteryPolicy().reduce(prior, { asOf: appendReceiptAt });
102103
const stageByOutcome = new Map(projection.outcomes.map((outcome) => [outcome.outcomeId, outcome.stage]));
103104
const supportEventIds = events
104105
.filter((event) => event.kind === "SupportProvided")
105106
.map((event) => event.eventId);
106107
const credit = await sealLearningEvent({
107108
...envelope("CreditGranted", `${identity}:credit`),
108-
appendReceiptAt: evidence.occurredAt,
109+
appendReceiptAt,
109110
attemptFingerprint: evidence.attemptFingerprint,
110111
checkEventIds: [check.eventId],
111112
creditSlices: outcomeIds.map((outcomeId) => ({
@@ -118,7 +119,7 @@ export async function buildCanonicalStrongCheckEvents(
118119
supportEventIds,
119120
});
120121
const candidateProjection = await new MasteryPolicy().reduce([...prior, ...events, credit], {
121-
asOf: evidence.occurredAt,
122+
asOf: appendReceiptAt,
122123
});
123124
return candidateProjection.invalidEventIds.includes(credit.eventId)
124125
? events

editor/src/lib/curriculumAssessmentQueue.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { nestedCanonicalLearningEvents } from "@/lib/canonicalLearningEvidence";
2+
import { evidenceAvailabilityTime, parseEvidenceTime } from "@/lib/evidenceTime";
23
import type { LearningEvent } from "@/lib/learningEvent";
34
import { MasteryPolicy } from "@/lib/masteryPolicy";
45
import type { WebStrongCheckEvidenceEvent } from "@/lib/webLearningEvidence";
@@ -27,7 +28,10 @@ export async function dueAssessmentSectionIds(
2728
if (!Number.isFinite(now)) throw new Error("assessment queue time must be finite");
2829
const canonicalEvents = nestedCanonicalLearningEvents(evidenceEvents);
2930
const projection = await new MasteryPolicy().reduce(canonicalEvents, { asOf: new Date(now).toISOString() });
30-
const accepted = acceptedCredits(canonicalEvents, projection.invalidEventIds, projection.outcomes.flatMap(
31+
const accepted = acceptedCredits(canonicalEvents, [
32+
...projection.invalidEventIds,
33+
...projection.deferredCreditEventIds,
34+
], projection.outcomes.flatMap(
3135
(outcome) => outcome.creditEventIds,
3236
));
3337
const masteryByOutcome = new Map(projection.outcomes.map((outcome) => [outcome.outcomeId, outcome]));
@@ -81,8 +85,10 @@ function acceptedCredits(
8185
const slices = Array.isArray(credit.creditSlices)
8286
? credit.creditSlices.filter(isRecord)
8387
: [];
84-
const evidenceTime = Date.parse(String(credit.evidenceTime));
85-
if (!Number.isFinite(evidenceTime)) continue;
88+
const evidenceTime = evidenceAvailabilityTime(parseEvidenceTime(
89+
credit.evidenceTime,
90+
credit.appendReceiptAt,
91+
));
8692
credits.push({
8793
evidenceTime,
8894
outcomeIds: slices.map((slice) => String(slice.outcomeId ?? "")).filter(Boolean),

editor/src/lib/curriculumLearningProjection.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { nestedCanonicalLearningEvents } from "@/lib/canonicalLearningEvidence";
2+
import { evidenceAvailabilityTime, parseEvidenceTime } from "@/lib/evidenceTime";
23
import type { CreditMode, LearningEvent } from "@/lib/learningEvent";
34
import { MasteryPolicy, type MasteryProjection, type OutcomeMasteryState } from "@/lib/masteryPolicy";
45
import type { WebStrongCheckEvidenceEvent } from "@/lib/webLearningEvidence";
@@ -47,6 +48,7 @@ type AcceptedCredit = {
4748
creditMode: CreditMode;
4849
eventId: string;
4950
evidenceAt: string;
51+
availableAt: string;
5052
lessonRef: string;
5153
outcomeIds: string[];
5254
sectionId: string;
@@ -197,20 +199,20 @@ function projectLessonReview(
197199
for (const retrieval of contract.retrievals) {
198200
const completedRetrieval = retrievalCredits
199201
.filter((credit) => credit.sectionId === retrieval.sectionId)
200-
.sort((left, right) => compareTimestamps(right.evidenceAt, left.evidenceAt))[0];
202+
.sort((left, right) => compareTimestamps(right.availableAt, left.availableAt))[0];
201203
const sourceCredits = credits.filter((credit) => (
202204
retrieval.sourceSectionIds.length
203205
? retrieval.sourceSectionIds.includes(credit.sectionId)
204206
: credit.outcomeIds.some((outcomeId) => retrieval.outcomeIds.includes(outcomeId))
205207
));
206-
const latestSource = sourceCredits.sort((left, right) => compareTimestamps(right.evidenceAt, left.evidenceAt))[0];
208+
const latestSource = sourceCredits.sort((left, right) => compareTimestamps(right.availableAt, left.availableAt))[0];
207209
if (
208210
!latestSource
209-
|| (completedRetrieval && Date.parse(completedRetrieval.evidenceAt) >= Date.parse(latestSource.evidenceAt))
211+
|| (completedRetrieval && Date.parse(completedRetrieval.availableAt) >= Date.parse(latestSource.availableAt))
210212
) continue;
211213
const intervalDays = Math.max(1, Math.ceil(retrieval.minimumDelayHours / 24));
212214
dueCandidates.push({
213-
dueAt: new Date(Date.parse(latestSource.evidenceAt) + retrieval.minimumDelayHours * 3_600_000).toISOString(),
215+
dueAt: new Date(Date.parse(latestSource.availableAt) + retrieval.minimumDelayHours * 3_600_000).toISOString(),
214216
intervalDays,
215217
outcomeIds: retrieval.outcomeIds,
216218
});
@@ -275,6 +277,10 @@ function acceptedCredits(events: LearningEvent[], mastery: MasteryProjection): A
275277
creditMode: creditMode as CreditMode,
276278
eventId,
277279
evidenceAt: String(credit.evidenceTime),
280+
availableAt: new Date(evidenceAvailabilityTime(parseEvidenceTime(
281+
credit.evidenceTime,
282+
credit.appendReceiptAt,
283+
))).toISOString(),
278284
lessonRef: String(context.lessonRef),
279285
outcomeIds,
280286
sectionId: String(context.sectionId),

editor/src/lib/evidenceTime.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
export type ClockAnomalyReason =
2+
| "evidence-after-receipt"
3+
| "evidence-time-regression"
4+
| "append-receipt-regression"
5+
| "elapsed-time-divergence";
6+
7+
export type ClockAnomaly = {
8+
creditEventId: string;
9+
outcomeId: string;
10+
reason: ClockAnomalyReason;
11+
evidenceTime: string;
12+
appendReceiptAt: string;
13+
previousEvidenceTime: string | null;
14+
previousAppendReceiptAt: string | null;
15+
};
16+
17+
export type EvidenceTime = {
18+
evidenceTime: number;
19+
appendReceiptAt: number;
20+
};
21+
22+
export function parseEvidenceTime(evidenceTime: unknown, appendReceiptAt: unknown): EvidenceTime {
23+
const parsed = {
24+
evidenceTime: Date.parse(String(evidenceTime)),
25+
appendReceiptAt: Date.parse(String(appendReceiptAt)),
26+
};
27+
if (!Number.isFinite(parsed.evidenceTime) || !Number.isFinite(parsed.appendReceiptAt)) {
28+
throw new Error("evidence timestamps must be valid ISO timestamps");
29+
}
30+
return parsed;
31+
}
32+
33+
export function evidenceAvailabilityTime(value: EvidenceTime): number {
34+
return Math.max(value.evidenceTime, value.appendReceiptAt);
35+
}
36+
37+
export function clockAnomalies(
38+
current: EvidenceTime,
39+
input: {
40+
creditEventId: string;
41+
outcomeId: string;
42+
previous: EvidenceTime | null;
43+
delayed: boolean;
44+
maximumFutureSkewSeconds: number;
45+
maximumElapsedDivergenceSeconds: number;
46+
},
47+
): ClockAnomaly[] {
48+
const reasons: ClockAnomalyReason[] = [];
49+
if (current.evidenceTime > current.appendReceiptAt + input.maximumFutureSkewSeconds * 1_000) {
50+
reasons.push("evidence-after-receipt");
51+
}
52+
if (input.previous !== null) {
53+
if (current.evidenceTime < input.previous.evidenceTime) reasons.push("evidence-time-regression");
54+
if (current.appendReceiptAt < input.previous.appendReceiptAt) reasons.push("append-receipt-regression");
55+
if (
56+
input.delayed
57+
&& Math.abs(
58+
(current.evidenceTime - input.previous.evidenceTime)
59+
- (current.appendReceiptAt - input.previous.appendReceiptAt),
60+
) > input.maximumElapsedDivergenceSeconds * 1_000
61+
) {
62+
reasons.push("elapsed-time-divergence");
63+
}
64+
}
65+
return reasons.map((reason) => ({
66+
creditEventId: input.creditEventId,
67+
outcomeId: input.outcomeId,
68+
reason,
69+
evidenceTime: new Date(current.evidenceTime).toISOString(),
70+
appendReceiptAt: new Date(current.appendReceiptAt).toISOString(),
71+
previousEvidenceTime: input.previous === null
72+
? null
73+
: new Date(input.previous.evidenceTime).toISOString(),
74+
previousAppendReceiptAt: input.previous === null
75+
? null
76+
: new Date(input.previous.appendReceiptAt).toISOString(),
77+
}));
78+
}

editor/src/lib/generatedContracts/artifactOwnership.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Generated by docs/skills/ops/tools/genProductContracts.py.
22
// Source SHA-256: 4679b9c14619d69672ff06da0291dd45c71b100ee6e8a0b5698fb96cd55eb113
3-
// Owners SHA-256: ac8031dd9d74489afae8f755033c66df5820500e36b65271a302c8105a68eb54
3+
// Owners SHA-256: 04be59aad7b836587bf7a0f695b6c3e70f06ce6fd5f266f0d12efc4d2035c01e
44
export const ARTIFACT_OWNERSHIP_CONTRACT_SHA256 = "4679b9c14619d69672ff06da0291dd45c71b100ee6e8a0b5698fb96cd55eb113" as const;
5-
export const ARTIFACT_OWNERSHIP_OWNERS_SHA256 = "ac8031dd9d74489afae8f755033c66df5820500e36b65271a302c8105a68eb54" as const;
5+
export const ARTIFACT_OWNERSHIP_OWNERS_SHA256 = "04be59aad7b836587bf7a0f695b6c3e70f06ce6fd5f266f0d12efc4d2035c01e" as const;
66

77
export type ArtifactRole = "source" | "generated" | "packaged" | "evidence";
88

0 commit comments

Comments
 (0)