Skip to content

Commit 48ee20c

Browse files
committed
학습 채점을 단일 의미로 모으고 보이지 않는 차이를 관용한다
상황: 출력 채점기가 네 곳(웹 연습 learningAttemptCheck, 웹 강검증 browserLearningCheckExecutor, 로컬 강검증 localStrongCheck, 로컬 커널 checker)에 제각각 구현되어 있었다. 웹 연습과 강검증은 마지막 줄바꿈만 정규화한 정확 일치라서 줄 끝 공백 하나, CRLF, 대소문자 하나로 실패했고, 로컬 커널은 반대로 공백을 전부 뭉개 과잉 관용이었다. 실패 피드백은 기대와 현재 출력 전문을 나열할 뿐 무엇이 다른지 짚지 않아, 기초 커리큘럼에서 문제가 이해되지 않고 띄어쓰기까지 강요된다는 사용성 문제로 이어졌다. 변경: - src/codaro/curriculum/outputMatch.py 를 채점 비교의 SSOT 로 신설했다. line-trim 정규화(CRLF 통일, 줄 끝 공백 제거, 앞뒤 빈 줄 제거)로 보이지 않는 차이만 관용하고, 실패 시 계층 피드백을 만든다: 대소문자만 다르면 그 사실과 두 표기를, 공백/줄바꿈만 다르면 그 사실을, 그 외에는 처음 다른 줄 번호와 기대/현재 줄을 보여준다. 빈 출력, 줄 수 차이도 짚는다. - editor/src/lib/learningOutputMatch.ts 에 같은 규칙의 TS 미러를 만들고, contracts/learning-content/outputMatchVectors.json 계약 벡터 16개를 두 구현이 함께 통과하도록 tests/learning/testOutputMatch.py(pytest)와 tests/learning/verifyOutputMatchParity.mjs(node, learning-content 게이트에 편입)로 고정했다. - 채점기 네 곳을 전부 공유 매처로 배선했다. 웹 연습은 checkConfig 의 caseInsensitive: true 옵트인(대소문자가 학습 목표와 무관한 검사 전용)을 지원한다. 강검증의 variable/behavior 구조 비교는 기존 그대로다. - 강검증 normalization 라벨의 canonical 을 line-trim 으로 바꿨다 (learningCheckSpec.ts, localStrongCheck.py, schema.yaml, 문서, 테스트). 기존 YAML 의 trim-final-newline 라벨은 로컬 검증기가 한시 수용하며, outputExact 41개 콘텐츠 전수 패스 사이클에서 YAML 개명과 함께 제거한다. - tests/learning/verifyLearningSectionCardContract.py 의 정확 비교 리터럴 pin 을 공유 매처 호출 pin 으로 바꾸고, tests/product/testCheckSandboxRuntimePolicy.py 의 모듈 mock 에 새 의존성을 추가했다. 영향: - 학습자가 줄 끝 공백, 마지막 줄바꿈, 개행 방식으로 틀리는 일이 없어진다. 대소문자와 줄 안 공백 차이는 여전히 불일치지만 피드백이 정확히 무엇이 다른지 알려준다. 통과 판정이 새로 후해지는 것은 옵트인 검사뿐이라 거짓 숙달을 만들지 않는다. - day02 변수명 스타일 검사의 기대값에 있던 끝 공백은 새 정규화가 자동 관용한다. 기대값 자체 정리와 41개 outputExact 프롬프트 명료화는 다음 콘텐츠 사이클에서 진행한다. 검증: - uv run python -X utf8 -m pytest tests/ --ignore=tests/_attempts --ignore=tests/assets 1619 passed (assets 6건은 작업 트리의 별도 진행 중 변경이 만든 캡처 provenance 드리프트로, 본 커밋 범위 밖이다) - node --experimental-strip-types tests/learning/verifyOutputMatchParity.mjs ok (16 vectors) - cd editor && npx tsc -b 통과 - uv run python -X utf8 tests/run.py gate learning-method 통과 - uv run python -X utf8 tests/run.py gate web-learning 은 채점 케이스 전부 통과 후 web-run-mobile 의 상단 레인 겹침(작업 트리의 소셜 링크 추가 진행분)에서만 실패한다. 본 커밋 파일과 무관함을 확인했다. - uv run python -X utf8 tests/run.py audit-self 통과
1 parent dc940a7 commit 48ee20c

18 files changed

Lines changed: 598 additions & 37 deletions
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
{
2+
"description": "학습 출력 비교 계약 벡터. Python(src/codaro/curriculum/outputMatch.py)과 TS(editor/src/lib/learningOutputMatch.ts)가 같은 결과를 내야 한다.",
3+
"schemaVersion": 1,
4+
"vectors": [
5+
{
6+
"id": "exact-plain",
7+
"expected": "Hello Codaro",
8+
"actual": "Hello Codaro",
9+
"caseInsensitive": false,
10+
"passed": true,
11+
"tier": "exact"
12+
},
13+
{
14+
"id": "trailing-space-forgiven",
15+
"expected": "Hello Codaro",
16+
"actual": "Hello Codaro ",
17+
"caseInsensitive": false,
18+
"passed": true,
19+
"tier": "exact"
20+
},
21+
{
22+
"id": "final-newline-forgiven",
23+
"expected": "합계 100",
24+
"actual": "합계 100\n\n",
25+
"caseInsensitive": false,
26+
"passed": true,
27+
"tier": "exact"
28+
},
29+
{
30+
"id": "crlf-forgiven",
31+
"expected": "a\nb",
32+
"actual": "a\r\nb\r\n",
33+
"caseInsensitive": false,
34+
"passed": true,
35+
"tier": "exact"
36+
},
37+
{
38+
"id": "per-line-trailing-space-forgiven",
39+
"expected": "카멜케이스 : Codaro User\n스네이크케이스 : Codaro Learner",
40+
"actual": "카멜케이스 : Codaro User \n스네이크케이스 : Codaro Learner ",
41+
"caseInsensitive": false,
42+
"passed": true,
43+
"tier": "exact"
44+
},
45+
{
46+
"id": "leading-blank-lines-forgiven",
47+
"expected": "결과",
48+
"actual": "\n\n결과",
49+
"caseInsensitive": false,
50+
"passed": true,
51+
"tier": "exact"
52+
},
53+
{
54+
"id": "case-diff-fails-with-note",
55+
"expected": "Hello Codaro",
56+
"actual": "hello codaro",
57+
"caseInsensitive": false,
58+
"passed": false,
59+
"tier": "caseOnly"
60+
},
61+
{
62+
"id": "case-diff-passes-when-opted-in",
63+
"expected": "Hello Codaro",
64+
"actual": "hello codaro",
65+
"caseInsensitive": true,
66+
"passed": true,
67+
"tier": "caseInsensitive"
68+
},
69+
{
70+
"id": "internal-double-space-fails-as-whitespace",
71+
"expected": "합계 : 100",
72+
"actual": "합계 : 100",
73+
"caseInsensitive": false,
74+
"passed": false,
75+
"tier": "whitespaceOnly"
76+
},
77+
{
78+
"id": "line-structure-fails-as-whitespace",
79+
"expected": "a b",
80+
"actual": "a\nb",
81+
"caseInsensitive": false,
82+
"passed": false,
83+
"tier": "whitespaceOnly"
84+
},
85+
{
86+
"id": "case-and-space-fails-as-whitespace",
87+
"expected": "Total : 100",
88+
"actual": "total : 100",
89+
"caseInsensitive": false,
90+
"passed": false,
91+
"tier": "whitespaceOnly"
92+
},
93+
{
94+
"id": "real-difference",
95+
"expected": "합계 100",
96+
"actual": "합계 90",
97+
"caseInsensitive": false,
98+
"passed": false,
99+
"tier": "different"
100+
},
101+
{
102+
"id": "empty-output",
103+
"expected": "안녕하세요",
104+
"actual": "",
105+
"caseInsensitive": false,
106+
"passed": false,
107+
"tier": "different"
108+
},
109+
{
110+
"id": "second-line-difference",
111+
"expected": "1일차\n2일차\n3일차",
112+
"actual": "1일차\n둘째날\n3일차",
113+
"caseInsensitive": false,
114+
"passed": false,
115+
"tier": "different"
116+
},
117+
{
118+
"id": "missing-line",
119+
"expected": "a\nb\nc",
120+
"actual": "a\nb",
121+
"caseInsensitive": false,
122+
"passed": false,
123+
"tier": "different"
124+
},
125+
{
126+
"id": "case-insensitive-does-not-mask-real-difference",
127+
"expected": "HELLO",
128+
"actual": "GOODBYE",
129+
"caseInsensitive": true,
130+
"passed": false,
131+
"tier": "different"
132+
}
133+
]
134+
}

‎curricula/python/schema.yaml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@
5757
# type: "noError"
5858
# noError: "섹션 예제의 입력 데이터, 처리 함수, 출력 확인 단계가 순서대로 실행되어야 한다."
5959
# resultCheck: "출력 또는 변수 상태가 이 섹션에서 바꾼 업무 조건을 반영해야 한다."
60+
# # outputExact practice check 의 비교 의미는 line-trim 이다(줄 끝 공백,
61+
# # 마지막 줄바꿈, CRLF 는 자동 관용 - SSOT: src/codaro/curriculum/outputMatch.py).
62+
# # 대소문자가 학습 목표와 무관한 검사(임의 인사말 등)만 caseInsensitive 를 켠다.
63+
# # upper()/lower() 처럼 대소문자 자체가 목표인 검사에는 절대 켜지 않는다.
64+
# # check:
65+
# # type: "outputExact"
66+
# # outputExact: "Hello Codaro"
67+
# # caseInsensitive: true
6068
# # blocks는 structured card 뒤에 붙는 보조 자료 또는 legacy curriculum 변환용이다.
6169
# # 신규 레슨은 위 structured section fields를 우선하고, 표/영상/참고 링크만 blocks로 보조한다.
6270
# blocks:
@@ -92,7 +100,7 @@
92100
# payload:
93101
# comparator: "exact"
94102
# expected: "Hello Codaro"
95-
# normalization: "trim-final-newline"
103+
# normalization: "line-trim"
96104
# 현재 Web W0는 output과 callable return+fixture path를 함께 보는 behavior subset을 지원한다.
97105
# packageAssets는 저장소에 고정한 same-origin wheel만 허용하며 Worker boot 전에 SRI를 검증한다.
98106
# variable/file/table/image와 Local sandbox는 아직 지원된 척 작성하지 않는다.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ check:
9595
payload:
9696
comparator: exact
9797
expected: Hello Codaro
98-
normalization: trim-final-newline
98+
normalization: line-trim
9999
```
100100

101101
fixture hash는 key를 정렬한 compact JSON UTF-8 bytes의 SHA-256 SRI다. author gate가 hash를 다시 계산하므로 임의 문자열을 넣어 통과시킬 수 없다. `contracts/checkSandboxFeasibilityDecision.json`이 Web과 Local의 실행 가능 evidence를 함께 결정한다. Web `output`·직렬화 `variable`만 fresh pyproc Worker와 processWorker graph SRI, fixture hash, timeout, teardown을 통과하면 strong 후보가 되며 `behavior`는 Worker boot 전에 `localRequired`로 끝난다. Local `local-sandbox`는 같은 behavior spec을 별도 native Python 자식 프로세스에서 실행한다. Windows launcher 경로는 managed runtime·worker를 tree hash와 active release로 고정하고 AppContainer capability 0, Job Object, handle allowlist, HMAC named pipe, 실행별 ACL receipt v2로 fixture 밖 읽기, network와 child process를 OS 경계에서도 거부한다. 공유 DACL mutex는 동시 grant·revoke를 직렬화하며 회수 실패 receipt/profile은 다음 launcher 시작이 stale run으로 GC한다. Local package asset은 설치본 `CODARO_WEB_BUILD_ROOT`의 pinned wheel만 사용하고 cold 병렬 검사도 캐시를 만들지 않는다. Local 결과는 계약이 지원하는 Windows NT 10.0 build 19045 이상에서 응답의 `isolation=windows-appcontainer`와 실제 build 번호가 함께 확인된 경우에만 strong evidence로 append한다. 일반 개발 서버의 `python-audit-hook` 결과와 더 오래된 Windows 결과는 같은 판정을 통과해도 practice 피드백에만 사용한다. 임의 원격 URL이나 최신 버전 즉석 설치는 strong evidence에 사용할 수 없다.

‎editor/src/lib/browserLearningCheckExecutor.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type StrongLearningCheckSpecV1,
99
verifyLearningFixtureHash,
1010
} from "@/lib/learningCheckSpec";
11+
import { matchLearningOutput } from "@/lib/learningOutputMatch";
1112
import type { LearningEvidenceArtifact } from "@/lib/webLearningEvidence";
1213

1314
type PyProcManifestFile = {
@@ -396,7 +397,14 @@ export async function executeBrowserStrongCheck(
396397
if (payload.exception) {
397398
return failed("error", expected, actual, concisePythonError(payload.exception));
398399
}
399-
if (actual !== expected) {
400+
// output 검사는 공유 매처(line-trim + 정밀 피드백)로 판정한다.
401+
// variable/behavior 는 구조화 값이라 stableJson 동등 비교를 유지한다.
402+
if (spec.kind === "output") {
403+
const verdict = matchLearningOutput(expected, actual);
404+
if (!verdict.passed) {
405+
return failed("mismatch", expected, actual, verdict.feedback);
406+
}
407+
} else if (actual !== expected) {
400408
return failed("mismatch", expected, actual, `기대 출력은 ${displayOutput(expected)}이고, 현재 출력은 ${displayOutput(actual)}입니다.`);
401409
}
402410
return {

‎editor/src/lib/learningAttemptCheck.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ExecutionResult } from "@/types";
22
import { executeBrowserStrongCheck } from "@/lib/browserLearningCheckExecutor";
33
import { stringifyData } from "@/lib/displayFormat";
44
import { parseStrongLearningCheckSpec } from "@/lib/learningCheckSpec";
5+
import { matchLearningOutput, normalizeLearningOutput } from "@/lib/learningOutputMatch";
56
import { executeLocalStrongCheck } from "@/lib/localLearningCheckExecutor";
67
import type { LearningEvidenceArtifact, LearningEvidencePackageAsset } from "@/lib/webLearningEvidence";
78

@@ -88,14 +89,20 @@ export async function evaluateLearningAttempt(
8889
state: "unsupported",
8990
};
9091
}
91-
if (actual !== expected) {
92+
// 비교 의미의 SSOT 는 learningOutputMatch 다. 대소문자가 학습 목표와 무관한
93+
// 검사는 콘텐츠가 caseInsensitive 로 옵트인한다. 실패 피드백은 무엇이 다른지
94+
// (대소문자만/공백만/N번째 줄)를 정확히 짚는다.
95+
const verdict = matchLearningOutput(expected, actual, {
96+
caseInsensitive: checkConfig?.caseInsensitive === true,
97+
});
98+
if (!verdict.passed) {
9299
return {
93100
actual,
94101
checkId: "",
95102
evidence: "none",
96103
executor: "practice",
97104
expected,
98-
feedback: `기대 출력은 ${displayOutput(expected)}이고, 현재 출력은 ${displayOutput(actual)}입니다.`,
105+
feedback: verdict.feedback,
99106
fixtureHash: "",
100107
passed: false,
101108
source,
@@ -108,7 +115,7 @@ export async function evaluateLearningAttempt(
108115
evidence: "practice",
109116
executor: "practice",
110117
expected,
111-
feedback: "목표한 출력과 일치합니다.",
118+
feedback: verdict.feedback,
112119
fixtureHash: "",
113120
passed: true,
114121
source,
@@ -132,7 +139,8 @@ export function practiceActualOutput(result: ExecutionResult): string {
132139
}
133140

134141
function normalizeOutput(value: string): string {
135-
return value.replace(/\r\n?/g, "\n").trim();
142+
// 비교 의미의 SSOT 는 learningOutputMatch(line-trim)다.
143+
return normalizeLearningOutput(value);
136144
}
137145

138146
function decodePythonStringRepr(value: string): string {
@@ -185,10 +193,6 @@ function decodePythonStringRepr(value: string): string {
185193
return decoded;
186194
}
187195

188-
function displayOutput(value: string): string {
189-
return value ? `“${value.replace(/\n/g, " ↵ ")}”` : "빈 출력";
190-
}
191-
192196
function learnerFeedback(
193197
state: "error" | "mismatch" | "unsupported" | "verified",
194198
detail: string,

‎editor/src/lib/learningCheckSpec.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export type StrongOutputCheckSpecV1 = {
3838
payload: {
3939
comparator: "exact";
4040
expected: string;
41-
normalization: "trim-final-newline";
41+
normalization: "line-trim";
4242
};
4343
strength: "strong";
4444
timeoutMs: number;
@@ -88,7 +88,7 @@ export function parseStrongLearningCheckSpec(
8888
payload: {
8989
comparator: "exact",
9090
expected,
91-
normalization: "trim-final-newline",
91+
normalization: "line-trim",
9292
},
9393
};
9494
}
@@ -228,9 +228,9 @@ export async function verifyLearningFixtureHash(spec: StrongLearningCheckSpecV1)
228228
return spec.fixtureHash === `sha256-${bytesToBase64(new Uint8Array(digest))}`;
229229
}
230230

231-
export function normalizeLearningOutput(value: string): string {
232-
return value.replace(/\r\n?/g, "\n").replace(/\n+$/g, "");
233-
}
231+
// 비교 의미의 SSOT 는 learningOutputMatch(line-trim)다. 여기서 재수출해
232+
// 기존 소비자의 import 경로를 유지한다.
233+
export { normalizeLearningOutput } from "./learningOutputMatch";
234234

235235
function stableJson(value: unknown): string {
236236
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;

0 commit comments

Comments
 (0)