acmer-coach 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { parse, stringify } from 'yaml'
7
+ import fs from 'node:fs'
8
+ import { syncBuiltinESMExports } from 'node:module'
9
+
10
+ async function fixture(run, { cursor = 'A' } = {}) {
11
+ const dir = mkdtempSync(join(tmpdir(), 'coach-training-'))
12
+ const old = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
13
+ writeFileSync(join(dir, 'MAP.yaml'), stringify({ meta: { version: 1 }, nodes: [{ id: 'A', name: '基础', depends: [] }] }))
14
+ writeFileSync(join(dir, 'PROGRESS.yaml'), stringify({ version: 1, cursor, nodes: { A: { status: 'studying' } } }))
15
+ process.env.COACH_DATA_DIR = dir
16
+ process.env.COACH_DB = join(dir, 'unused.db')
17
+ try {
18
+ const { apply } = await import(`../index.js?training-${Date.now()}-${Math.random()}`)
19
+ const tools = new Map()
20
+ apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
21
+ const call = async (name, args) => {
22
+ const tool = tools.get(name)
23
+ const value = await tool.execute(args)
24
+ shape(tool.output.schema, value)
25
+ return value
26
+ }
27
+ await run({ call, tools, dir, read: file => parse(readFileSync(join(dir, file), 'utf8')), write: (file, doc) => writeFileSync(join(dir, file), stringify(doc)) })
28
+ } finally {
29
+ for (const [key, value] of [['COACH_DATA_DIR', old.data], ['COACH_DB', old.db]]) {
30
+ if (value === undefined) delete process.env[key]
31
+ else process.env[key] = value
32
+ }
33
+ rmSync(dir, { recursive: true, force: true })
34
+ }
35
+ }
36
+
37
+ test('a failed progress write cannot report the pending action as cleared', async () => fixture(async ({ call, read }) => {
38
+ assert.equal((await call('coach_assign', { competency: 'contest-reading', kind: 'diagnostic', minutes: 15, deliverable: '说明三题的模型条件' })).accepted, true)
39
+ const originalWrite = fs.writeFileSync
40
+ let result
41
+ try {
42
+ fs.writeFileSync = (path, ...args) => {
43
+ if (String(path).includes('PROGRESS.yaml')) throw new Error('simulated disk write failure')
44
+ return originalWrite(path, ...args)
45
+ }
46
+ syncBuiltinESMExports()
47
+ result = await call('coach_log', { competency: 'contest-reading', actualMin: 15, solved: true, independent: true })
48
+ } finally { fs.writeFileSync = originalWrite; syncBuiltinESMExports() }
49
+ assert.equal(result.recorded, false)
50
+ assert.equal(result.actionCleared, false)
51
+ assert.ok(read('PROGRESS.yaml').pendingActions['competency:contest-reading'])
52
+ assert.equal(read('SCHEDULE.yaml').days[result.date].actual.length, 1, 'the successfully saved actual remains visible')
53
+ }))
54
+
55
+ function shape(schema, value) {
56
+ if (schema.type === 'object') {
57
+ assert.ok(value && typeof value === 'object' && !Array.isArray(value))
58
+ if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected ${key}`)
59
+ for (const [key, field] of Object.entries(schema.properties ?? {})) {
60
+ if (field.required === true || schema.required?.includes(key)) assert.ok(key in value, `missing ${key}`)
61
+ if (key in value) shape(field, value[key])
62
+ }
63
+ } else if (schema.type === 'array') {
64
+ assert.ok(Array.isArray(value)); value.forEach(item => shape(schema.items, item))
65
+ } else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
66
+ else assert.equal(typeof value, schema.type)
67
+ }
68
+
69
+ test('15-minute node diagnostic fits one action without pretending a lecture or mastery happened', async () => fixture(async ({ call, read }) => {
70
+ const assigned = await call('coach_assign', { node: 'A', kind: 'diagnostic', minutes: 15, deliverable: '说出状态定义并解释为什么保留这些信息' })
71
+ assert.equal(assigned.accepted, true, assigned.reject)
72
+ assert.equal(assigned.totalMinutes, 15)
73
+ assert.deepEqual(assigned.blocks.map(block => block.minutes), [15])
74
+ const progress = read('PROGRESS.yaml')
75
+ assert.equal(progress.nodes.A.status, 'studying')
76
+ assert.equal(progress.nodes.A.taught, undefined)
77
+ assert.equal(progress.pendingActions.A.kind, 'diagnostic')
78
+ }))
79
+
80
+ test('a full 300-minute VP occupies one schedule block within the physical day', async () => fixture(async ({ call }) => {
81
+ const now = new Date()
82
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
83
+ const planned = await call('coach_plan', { date, blocks: [{ from: '10:00', node: 'A', kind: 'vp', minutes: 300 }] })
84
+ assert.equal(planned.ok, true, planned.reject)
85
+ assert.equal(planned.planned.length, 1)
86
+ assert.equal(planned.planned[0].to, '15:00')
87
+ assert.equal(planned.planMin, 300)
88
+ assert.equal((await call('coach_plan', { date, replace: true, blocks: [{ from: '20:00', node: 'A', kind: 'vp', minutes: 300 }] })).ok, false)
89
+ const schedule = await call('coach_schedule', { from: date, days: 1 })
90
+ assert.equal(schedule.days[0].planned[0].minutes, 300)
91
+ }))
92
+
93
+ test('competency diagnostic works without a cursor and completes through coach_log without a fake map node', async () => fixture(async ({ call, read }) => {
94
+ const assigned = await call('coach_assign', { competency: 'contest-reading', kind: 'diagnostic', minutes: 15, deliverable: '扫描题面,提交题目筛选顺序和放弃理由' })
95
+ assert.equal(assigned.accepted, true, assigned.reject)
96
+ assert.equal(assigned.node, '')
97
+ assert.equal(assigned.competency, 'contest-reading')
98
+ assert.equal(assigned.totalMinutes, 15)
99
+ assert.equal(read('PROGRESS.yaml').pendingActions['competency:contest-reading'].competency, 'contest-reading')
100
+ const status = await call('coach_status', {})
101
+ const pending = status.pendingActions[0]
102
+ assert.equal(pending.node, '')
103
+ assert.equal(pending.competency, 'contest-reading')
104
+ assert.equal(pending.kind, 'diagnostic')
105
+ assert.equal(pending.nodeName, '比赛读题与筛选')
106
+ const logged = await call('coach_log', { competency: 'contest-reading', actualMin: 15, solved: false, independent: false, note: '遗漏了最后一道题的输入限制' })
107
+ assert.equal(logged.ok, true, logged.reject)
108
+ assert.equal(logged.actionCleared, true)
109
+ assert.equal(logged.recorded, true)
110
+ assert.deepEqual(Object.keys(read('PROGRESS.yaml').nodes), ['A'])
111
+ assert.equal(read('SCHEDULE.yaml').days[logged.date].actual[0].competency, 'contest-reading')
112
+ assert.equal(read('PROGRESS.yaml').pendingActions, undefined)
113
+ const assessed = await call('coach_curriculum', { action: 'assess' })
114
+ assert.equal(assessed.evidence.find(entry => entry.competency === 'contest-reading')?.signal, 'gap')
115
+ assert.equal((await call('coach_assign', { competency: 'invented', kind: 'diagnostic', minutes: 15, deliverable: '提交观察' })).accepted, false)
116
+ assert.equal((await call('coach_log', { competency: 'invented', actualMin: 15, solved: true, independent: true })).ok, false)
117
+ }, { cursor: '' }))
118
+
119
+ test('competency actions bind the diagnostic phase, reject stale or other-unit work, and yield usable evidence', async () => fixture(async ({ call, read }) => {
120
+ const phase = { id: 'read-probe', title: '读题诊断', kind: 'diagnostic', finding: 'unknown', nodes: [], competencies: ['contest-reading'],
121
+ outcome: '确认读题筛选遗漏发生在哪个环节', exitCriteria: '记录一次新的读题诊断观察结果',
122
+ basis: '目前没有读题过程的独立观察记录', goalContribution: '识别区域赛首轮漏读与错误筛选问题', priorityReason: '先观察当前筛选过程再决定训练重点', estimatedHours: 1,
123
+ evidenceRefs: [], uncertainty: '目前缺少读题过程记录无法判断原因', diagnosticMethod: '限时扫描完整题面并解释筛选和放弃理由' }
124
+ const created = await call('coach_curriculum', { action: 'create', expectedRevision: 0, goal: '区域赛读题筛选更稳定', reason: '缺少当前读题事实,先安排一次小诊断建立依据', phases: [phase] })
125
+ assert.equal(created.ok, true, created.reject)
126
+ const action = { competency: 'contest-reading', kind: 'diagnostic', minutes: 15, deliverable: '提交筛选顺序和放弃理由', why: '观察是否遗漏输入限制和可做题', curriculumRevision: created.revision }
127
+ assert.equal((await call('coach_assign', { ...action, curriculumRevision: created.revision - 1 })).accepted, false)
128
+ assert.equal((await call('coach_assign', { ...action, competency: 'contest-decision' })).accepted, false)
129
+ const assigned = await call('coach_assign', action)
130
+ assert.equal(assigned.accepted, true, assigned.reject)
131
+ assert.equal(read('PROGRESS.yaml').pendingActions['competency:contest-reading'].curriculum.phaseId, 'read-probe')
132
+ assert.equal((await call('coach_assign', { node: 'A', deliverable: '提交代码', purpose: 'remediation', routeReason: '补充当前读题阶段的实现基础再回到读题诊断', why: '补齐基础', curriculumRevision: created.revision })).accepted, false)
133
+ const logged = await call('coach_log', { competency: 'contest-reading', problemId: 'probe-1', actualMin: 15, solved: false, independent: false })
134
+ assert.equal(logged.actionCleared, true)
135
+ const state = await call('coach_curriculum', { action: 'read' })
136
+ assert.equal(state.pendingNodes.length, 0)
137
+ assert.equal(state.missingFreshEvidence.length, 0)
138
+ assert.equal(state.evidence[0].competency, 'contest-reading')
139
+ }, { cursor: '' }))
140
+
141
+ test('competency VP can be scheduled and withdrawn without fake nodes', async () => fixture(async ({ call }) => {
142
+ const now = new Date()
143
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
144
+ const planned = await call('coach_plan', { date, blocks: [{ from: '10:00', competency: 'contest-reading', kind: 'vp', minutes: 300 }] })
145
+ assert.equal(planned.ok, true, planned.reject)
146
+ assert.equal(planned.planned[0].node, '')
147
+ assert.equal(planned.planned[0].competency, 'contest-reading')
148
+ const schedule = await call('coach_schedule', { from: date, days: 1 })
149
+ assert.equal(schedule.days[0].planned[0].nodeName, '比赛读题与筛选')
150
+ assert.equal(schedule.days[0].planned[0].competency, 'contest-reading')
151
+ assert.equal((await call('coach_assign', { competency: 'contest-reading', kind: 'vp', minutes: 300, deliverable: '完成比赛并提交首轮筛选过程' })).accepted, true)
152
+ const withdrawn = await call('coach_unassign', { competency: 'contest-reading', reason: '队友临时有事,本次合练取消' })
153
+ assert.equal(withdrawn.ok, true, withdrawn.reject)
154
+ assert.equal(withdrawn.node, '')
155
+ assert.equal(withdrawn.competency, 'contest-reading')
156
+ const logged = await call('coach_log', { competency: 'contest-reading', problemId: 'VP-test', actualMin: 300, solved: true, independent: true })
157
+ assert.equal(logged.plannedMin, 300)
158
+ assert.equal((await call('coach_schedule', { from: date, days: 1 })).days[0].actual[0].competency, 'contest-reading')
159
+ assert.equal((await call('coach_unplan', { date, all: true })).removed[0].competency, 'contest-reading')
160
+ }, { cursor: '' }))
161
+
162
+ test('explicit durations preserve defaults and reject invalid lengths, busy collisions and overlap atomically', async () => fixture(async ({ call, read, write }) => {
163
+ const now = new Date()
164
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
165
+ const defaults = await call('coach_plan', { date, blocks: [{ from: '08:00', node: 'A', kind: 'cycle' }, { from: '09:00', node: 'A', kind: 'review' }, { from: '10:00', node: 'A', kind: 'exam' }] })
166
+ assert.equal(defaults.ok, true, defaults.reject)
167
+ assert.deepEqual(defaults.planned.map(block => block.minutes), [60, 25, 90])
168
+ const previous = read('SCHEDULE.yaml')
169
+ for (const minutes of [0, -1, 961]) {
170
+ assert.equal((await call('coach_plan', { date, replace: true, blocks: [{ from: '08:00', node: 'A', kind: 'cycle', minutes }] })).ok, false)
171
+ assert.equal((await call('coach_assign', { node: 'A', kind: 'diagnostic', minutes, deliverable: '提交观察' })).accepted, false)
172
+ }
173
+ for (const minutes of [NaN, Infinity, 1.5]) await assert.rejects(call('coach_plan', { date, replace: true, blocks: [{ from: '08:00', node: 'A', kind: 'cycle', minutes }] }), /invalid arguments/)
174
+ assert.deepEqual(read('SCHEDULE.yaml'), previous)
175
+ assert.equal((await call('coach_assign', { node: 'A', competency: 'contest-reading', deliverable: '提交观察' })).accepted, false)
176
+ assert.equal((await call('coach_assign', { node: 'A', minutes: 15, deliverable: '完成一个很小的迁移题' })).totalMinutes, 35)
177
+ const busy = { version: 1, days: { [date]: { busy: [{ from: '12:00', to: '13:00', label: '上课' }] } } }
178
+ write('SCHEDULE.yaml', busy)
179
+ assert.equal((await call('coach_plan', { date, blocks: [{ from: '10:00', node: 'A', kind: 'vp', minutes: 300 }] })).ok, false)
180
+ assert.deepEqual(read('SCHEDULE.yaml'), busy)
181
+ assert.equal((await call('coach_plan', { date, blocks: [{ from: '08:00', node: 'A', kind: 'diagnostic', minutes: 15 }, { from: '08:10', node: 'A', kind: 'diagnostic', minutes: 15 }] })).ok, false)
182
+ assert.deepEqual(read('SCHEDULE.yaml'), busy)
183
+ }))
package/dev.sh CHANGED
@@ -35,7 +35,7 @@ done
35
35
  # 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
36
36
  # 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
37
37
  # 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
38
- SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/workspace-migration.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js lib/teaching.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/NODE_SCOPES.yaml assets/knowledge/CONTEST_PROFILES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/run-all.sh docs/长期课程机制.md docs/比赛与教学依据.md docs/设计方针.md dev.sh'
38
+ SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/workspace-migration.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js lib/teaching.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/NODE_SCOPES.yaml assets/knowledge/CONTEST_PROFILES.yaml assets/knowledge/vp.html assets/knowledge/skilltree.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/grading-check.mjs dev/training-check.mjs coach/skilltree_template.html dev/run-all.sh docs/长期课程机制.md docs/比赛与教学依据.md docs/设计方针.md dev.sh'
39
39
  STALE=''
40
40
  for f in $SYNC_FILES; do
41
41
  for d in "${DSTS[@]}"; do
@@ -120,7 +120,7 @@ EVIDENCE_RC=${EVIDENCE_RC:-0}
120
120
 
121
121
  echo
122
122
  echo '── 策略、教学目录、真实工具与账号数据回归 ──'
123
- node --test "$SRC_BASH/dev/strategy-check.mjs" "$SRC_BASH/dev/catalog-check.mjs" "$SRC_BASH/dev/coaching-contract-check.mjs" "$SRC_BASH/dev/teaching-check.mjs" "$SRC_BASH/dev/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
123
+ node --test "$SRC_BASH/dev/strategy-check.mjs" "$SRC_BASH/dev/catalog-check.mjs" "$SRC_BASH/dev/coaching-contract-check.mjs" "$SRC_BASH/dev/teaching-check.mjs" "$SRC_BASH/dev/grading-check.mjs" "$SRC_BASH/dev/training-check.mjs" "$SRC_BASH/dev/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
124
124
  CONTRACT_RC=${CONTRACT_RC:-0}
125
125
 
126
126
  LEDGER_AFTER=$(ledger_fp)
@@ -12,6 +12,12 @@
12
12
 
13
13
  `NODE_SCOPES` 的 `id` 必须与 MAP 节点完全一致。`recognition` 用于读题识别;`proofQuestions` 用于确认学生能解释;`notFor` 用于主动暴露边界;`transferProblems` 是练习任务描述,不是假造的题目编号;`exitEvidence` 是教学验收建议。来源证明技术条件,练习设计由插件负责。
14
14
 
15
+ ## 2.6:中国亚洲区域赛的逐题依据
16
+
17
+ 默认通用配置 `regional-diagnostic` 面向中国亚洲区域赛:南京 2022 全卷十三题和南京 2023 六题,共十九题,逐题列年份、模型、能力、题解核验状态和页码。来源是 [SUA 2022 命题组题解](https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf)与 [QOJ 镜像的 SUA 2023 题解](https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf)。NWERC 放在独立的 `nwerc-reference` 配置,供扩展讨论。
18
+
19
+ `coverage` 自动汇总真实样本数量、题解核验数量及年份;没有样本就返回零。西安 [QOJ 历年目录](https://qoj.ac/category/87)已核对,原始题解附件未获取,因此维持待采样,不从题名补造算法。十九题不是中国区域赛专题频率统计;已核对模型也不保证唯一解法、完整考纲或本届命题。教练结合个人和团队的新证据决定重点,不按 rating 解锁下一章。
20
+
15
21
  三个 profile 当前均是 `provisional`:`regional-diagnostic`、`wf-extension`、`xian-pending`。这个状态表示可用于带限制的诊断,不能输出“已掌握本比赛考纲”。`reviewed` 也只能表示记录的样本已人工核验,不能去掉样本限制。
16
22
 
17
23
  ## 本轮采样范围
@@ -92,7 +92,7 @@
92
92
  不写 = 未学
93
93
  studying = 在学 开了头、还没达标。跨天是常态
94
94
  learned = 学过 自评学过,没做检测
95
- verified = 已验证 过了章节检测,有证据
95
+ verified = 已验证 新检测须明确独立且过卷;旧证书不补造独立事实
96
96
  ```
97
97
 
98
98
  把 learned 和 verified 合成一个「会/不会」,就等于退回原则 2 要治的那个病。