acmer-coach 2.3.0 → 2.4.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.
- package/.github/workflows/publish.yml +2 -0
- package/README.md +35 -5
- package/assets/knowledge/COMPETENCIES.yaml +39 -4
- package/assets/knowledge/CONTEST_PROFILES.yaml +61 -0
- package/assets/knowledge/NODE_SCOPES.yaml +201 -0
- package/assets/rules/coach-rules.md +27 -3
- package/dev/catalog-check.mjs +74 -0
- package/dev/coaching-contract-check.mjs +93 -0
- package/dev/curriculum-check.mjs +193 -5
- package/dev/curriculum-evidence-check.mjs +9 -2
- package/dev/parity-check.mjs +9 -3
- package/dev/run-all.sh +5 -0
- package/dev/strategy-check.mjs +163 -4
- package/dev/vp-client-check.mjs +12 -2
- package/dev/vp-sync-check.mjs +122 -0
- package/dev/workspace-migration-check.mjs +106 -0
- package/dev.sh +8 -3
- package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md +48 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +17 -1
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +33 -6
- package/index.js +106 -34
- package/lib/curriculum-evidence.js +16 -2
- package/lib/curriculum-planning.js +75 -6
- package/lib/curriculum.js +44 -16
- package/lib/strategy.js +159 -51
- package/lib/vp-client.js +14 -2
- package/lib/workspace-migration.js +38 -0
- package/package.json +7 -1
- package/verify.mjs +1 -1
package/dev/vp-client-check.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { createVpClient } from '../lib/vp-client.js'
|
|
|
7
7
|
const root = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-'))
|
|
8
8
|
let account = { id: 1, handle: 'A', display_name: 'Coach A' }
|
|
9
9
|
let mismatch = false
|
|
10
|
+
let nextPage = false
|
|
10
11
|
const fetchImpl = async (url, options = {}) => {
|
|
11
12
|
const path = new URL(url).pathname
|
|
12
13
|
const body = options.body ? JSON.parse(options.body) : {}
|
|
@@ -19,8 +20,8 @@ const fetchImpl = async (url, options = {}) => {
|
|
|
19
20
|
if (path.endsWith('/api/coach/contests')) return json({ account, contests: [{ id: 7, title: 'VP 7', status: 'ended' }] })
|
|
20
21
|
if (path.endsWith('/api/coach/sync')) return json({
|
|
21
22
|
account: mismatch ? { id: 1, handle: 'A' } : account,
|
|
22
|
-
submissions: [{ id: 9, contest_id: 7, verdict: 'AC' }], events: [], contests: [],
|
|
23
|
-
next_cursor: { submission_id: 9, event_id: 0 }, has_more:
|
|
23
|
+
submissions: [{ id: nextPage ? 10 : 9, contest_id: 7, verdict: nextPage ? 'WA' : 'AC' }], events: [], contests: [],
|
|
24
|
+
next_cursor: { submission_id: nextPage ? 10 : 9, event_id: 0 }, has_more: !nextPage,
|
|
24
25
|
})
|
|
25
26
|
if (path.endsWith('/api/coach/replay/7')) return json({ account, visualization: { team_events: [] } })
|
|
26
27
|
throw new Error(`unexpected ${url}`)
|
|
@@ -34,7 +35,16 @@ try {
|
|
|
34
35
|
await client.finish({ code: 'A' })
|
|
35
36
|
const synced = await client.sync()
|
|
36
37
|
assert.equal(synced.account.id, 1)
|
|
38
|
+
assert.equal(synced.hasMore, true, 'first page must expose continuation')
|
|
39
|
+
assert.deepEqual(synced.pageCounts, { submissions: 1, events: 0, contests: 0 })
|
|
37
40
|
assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).account.id, 1)
|
|
41
|
+
nextPage = true
|
|
42
|
+
const secondPage = await client.sync()
|
|
43
|
+
assert.equal(secondPage.hasMore, false, 'last page must expose completion')
|
|
44
|
+
assert.deepEqual(secondPage.pageCounts, { submissions: 1, events: 0, contests: 0 }, 'page counts must not include old cached rows')
|
|
45
|
+
assert.deepEqual(secondPage.submissions.map(item => item.id), [9, 10], 'incremental pages must retain earlier accepted runs')
|
|
46
|
+
assert.deepEqual(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).submissions.map(item => item.id), [9, 10])
|
|
47
|
+
nextPage = false
|
|
38
48
|
await client.finish({ code: 'B' })
|
|
39
49
|
assert.equal((await client.status()).account.id, 2)
|
|
40
50
|
assert.equal((await client.contests()).contests[0].id, 7)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
function shape(schema, value) {
|
|
8
|
+
if (schema.type === 'object') {
|
|
9
|
+
assert.ok(value && typeof value === 'object' && !Array.isArray(value))
|
|
10
|
+
if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected field ${key}`)
|
|
11
|
+
for (const [key, field] of Object.entries(schema.properties ?? {})) {
|
|
12
|
+
if (field.required) assert.ok(key in value, `missing field ${key}`)
|
|
13
|
+
if (key in value) shape(field, value[key])
|
|
14
|
+
}
|
|
15
|
+
} else if (schema.type === 'array') {
|
|
16
|
+
assert.ok(Array.isArray(value))
|
|
17
|
+
if (schema.items) value.forEach(item => shape(schema.items, item))
|
|
18
|
+
} else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
|
|
19
|
+
else assert.equal(typeof value, schema.type)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fixture(run) {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-vp-sync-'))
|
|
24
|
+
const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB, fetch: globalThis.fetch }
|
|
25
|
+
const account = { id: 1, handle: 'mock-account', display_name: 'Mock account' }
|
|
26
|
+
let page = 0
|
|
27
|
+
let mode = 'normal'
|
|
28
|
+
const calls = []
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes: []\n')
|
|
31
|
+
process.env.COACH_DATA_DIR = dir
|
|
32
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
33
|
+
globalThis.fetch = async url => {
|
|
34
|
+
const parsed = new URL(url)
|
|
35
|
+
if (parsed.pathname === '/api/coach/auth/exchange') return json({ access_token: 'test-token', user: account })
|
|
36
|
+
if (parsed.pathname !== '/api/coach/sync') throw new Error(`unexpected mock route ${parsed.pathname}`)
|
|
37
|
+
if (mode === 'error') return { ok: false, status: 503, json: async () => ({ detail: 'temporary mock failure' }) }
|
|
38
|
+
calls.push(Number(parsed.searchParams.get('submission_cursor')))
|
|
39
|
+
const submissionId = mode === 'duplicate' ? page : ++page
|
|
40
|
+
const contest = { id: mode === 'invalid' ? 8 : 7, title: 'Mock VP', start_time: mode === 'invalid' ? '2026-02-30T00:00:00Z' : mode === 'naive-utc' ? '2026-10-10 00:00:00' : '2026-10-10T00:00:00Z', duration_min: 300 }
|
|
41
|
+
return json({ account, contests: [contest], events: [],
|
|
42
|
+
submissions: [{ id: submissionId, contest_id: contest.id, problem_id: 'A', verdict: 'AC', submitted_at: mode === 'naive-utc' ? '2026-10-10 00:10:00' : '2026-10-10T00:10:00Z' }],
|
|
43
|
+
next_cursor: { submission_id: submissionId, event_id: 0 }, has_more: page === 1 })
|
|
44
|
+
}
|
|
45
|
+
const { apply } = await import(`../index.js?vp-sync-${dir.replace(/\\/g, '/')}`)
|
|
46
|
+
const registered = new Map()
|
|
47
|
+
apply({ tools: { register: tool => registered.set(tool.name, tool) }, inject() {}, on() {} })
|
|
48
|
+
const call = async (name, args = {}) => {
|
|
49
|
+
const tool = registered.get(name)
|
|
50
|
+
const result = await tool.execute(args)
|
|
51
|
+
shape(tool.output.schema, result)
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
assert.equal((await call('coach_vp_connect', { action: 'finish', code: 'test-code' })).ok, true)
|
|
55
|
+
await run({ call, calls, mode: value => { mode = value } })
|
|
56
|
+
} finally {
|
|
57
|
+
globalThis.fetch = previous.fetch
|
|
58
|
+
for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
|
|
59
|
+
if (value === undefined) delete process.env[key]
|
|
60
|
+
else process.env[key] = value
|
|
61
|
+
}
|
|
62
|
+
rmSync(dir, { recursive: true, force: true })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function json(value) { return { ok: true, json: async () => value } }
|
|
67
|
+
|
|
68
|
+
test('public VP sync exposes two-page continuation and distinguishes page rows from cumulative cache', async () => {
|
|
69
|
+
await fixture(async ({ call, calls, mode }) => {
|
|
70
|
+
const first = await call('coach_vp_sync')
|
|
71
|
+
assert.equal(first.ok, true, first.reject)
|
|
72
|
+
assert.equal(first.hasMore, true)
|
|
73
|
+
assert.deepEqual(first.pageCounts, { submissions: 1, events: 0, contests: 1 })
|
|
74
|
+
const second = await call('coach_vp_sync')
|
|
75
|
+
assert.equal(second.ok, true, second.reject)
|
|
76
|
+
assert.equal(second.hasMore, false)
|
|
77
|
+
assert.deepEqual(second.pageCounts, { submissions: 1, events: 0, contests: 1 })
|
|
78
|
+
assert.equal(second.submissions.length, 2)
|
|
79
|
+
assert.deepEqual(calls, [0, 1])
|
|
80
|
+
assert.equal(second.nextCursor.submission_id, 2)
|
|
81
|
+
assert.match(second.text, /缓存累计/)
|
|
82
|
+
assert.doesNotMatch(second.text, /新增训练证据/)
|
|
83
|
+
mode('duplicate')
|
|
84
|
+
const repeated = await call('coach_vp_sync')
|
|
85
|
+
assert.equal(repeated.ok, true, repeated.reject)
|
|
86
|
+
assert.equal(repeated.submissions.length, 2)
|
|
87
|
+
assert.deepEqual(repeated.pageCounts, { submissions: 1, events: 0, contests: 1 })
|
|
88
|
+
assert.match(repeated.text, /缓存累计/)
|
|
89
|
+
assert.doesNotMatch(repeated.text, /新增训练证据/)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('public VP sync propagates training import refusal and network failure with complete output shapes', async () => {
|
|
94
|
+
await fixture(async ({ call, mode }) => {
|
|
95
|
+
mode('invalid')
|
|
96
|
+
const rejected = await call('coach_vp_sync')
|
|
97
|
+
assert.equal(rejected.ok, false, 'invalid imported date must not be reported as successful sync')
|
|
98
|
+
assert.match(rejected.reject, /VP 导入被拒绝/)
|
|
99
|
+
assert.equal(rejected.imported, 0)
|
|
100
|
+
mode('error')
|
|
101
|
+
const failed = await call('coach_vp_sync')
|
|
102
|
+
assert.equal(failed.ok, false)
|
|
103
|
+
assert.match(failed.reject, /503/)
|
|
104
|
+
assert.equal(failed.hasMore, false)
|
|
105
|
+
assert.deepEqual(failed.pageCounts, { submissions: 0, events: 0, contests: 0 })
|
|
106
|
+
assert.deepEqual(failed.nextCursor, { submission_id: 0, event_id: 0 })
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('platform UTC timestamps without a timezone become valid ISO evidence', async () => {
|
|
111
|
+
await fixture(async ({ call, mode }) => {
|
|
112
|
+
mode('naive-utc')
|
|
113
|
+
const synced = await call('coach_vp_sync')
|
|
114
|
+
assert.equal(synced.ok, true, synced.reject)
|
|
115
|
+
const facts = await call('coach_curriculum', { action: 'assess', evidenceEventIds: ['vp:1:7'] })
|
|
116
|
+
assert.equal(facts.evidence[0].occurredAt, '2026-10-10T00:10:00.000Z')
|
|
117
|
+
assert.equal(facts.evidence[0].minutes, 10)
|
|
118
|
+
const platformStart = new Date('2026-10-10T00:00:00Z')
|
|
119
|
+
const localDate = `${platformStart.getFullYear()}-${String(platformStart.getMonth() + 1).padStart(2, '0')}-${String(platformStart.getDate()).padStart(2, '0')}`
|
|
120
|
+
assert.equal(facts.evidence[0].date, localDate)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
const target = (contest) => `version: 1\nrevision: 1\nupdated: 2026-10-11T00:51:42.901Z\ncontest: ${contest}\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n`
|
|
8
|
+
let startupCount = 0
|
|
9
|
+
async function startup(root) {
|
|
10
|
+
const previous = process.env.COACH_DATA_DIR
|
|
11
|
+
const previousDb = process.env.COACH_DB
|
|
12
|
+
process.env.COACH_DATA_DIR = root
|
|
13
|
+
process.env.COACH_DB = join(root, 'unused.db')
|
|
14
|
+
try { await import(`../index.js?workspace-migration=${Date.now()}-${startupCount++}`) }
|
|
15
|
+
finally {
|
|
16
|
+
if (previous === undefined) delete process.env.COACH_DATA_DIR
|
|
17
|
+
else process.env.COACH_DATA_DIR = previous
|
|
18
|
+
if (previousDb === undefined) delete process.env.COACH_DB
|
|
19
|
+
else process.env.COACH_DB = previousDb
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function fixture(t) {
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), 'acmer-coach-workspace-'))
|
|
24
|
+
t.after(() => rmSync(root, { recursive: true, force: true }))
|
|
25
|
+
const accountDir = join(root, 'vp-accounts', '6')
|
|
26
|
+
mkdirSync(accountDir, { recursive: true })
|
|
27
|
+
writeFileSync(join(root, 'VP_ACCOUNTS.json'), JSON.stringify({
|
|
28
|
+
version: 1,
|
|
29
|
+
activeAccountId: 6,
|
|
30
|
+
accounts: { 6: { account: { id: 6, handle: 'Mianiii', display_name: 'Mianiii' }, baseUrl: 'https://example.test' } },
|
|
31
|
+
}))
|
|
32
|
+
writeFileSync(join(accountDir, 'TARGET.yaml'), target('账号旧目标'))
|
|
33
|
+
return { root, accountDir, main: join(root, 'TARGET.yaml'), source: join(accountDir, 'TARGET.yaml'), marker: join(accountDir, 'PERSONAL_MIGRATION.json') }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('startup fills missing personal files while retaining their account originals', async (t) => {
|
|
37
|
+
const f = fixture(t)
|
|
38
|
+
await startup(f.root)
|
|
39
|
+
assert.equal(readFileSync(f.main, 'utf8'), target('账号旧目标'))
|
|
40
|
+
assert.equal(readFileSync(f.source, 'utf8'), target('账号旧目标'))
|
|
41
|
+
assert.equal(existsSync(f.marker), true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('first migration preserves an existing global ledger even when the account mtime is newer', async (t) => {
|
|
45
|
+
const f = fixture(t)
|
|
46
|
+
writeFileSync(f.main, target('合法个人目标'))
|
|
47
|
+
const older = new Date('2026-10-10T12:00:00Z'), newer = new Date('2026-10-12T12:00:00Z')
|
|
48
|
+
utimesSync(f.main, older, older); utimesSync(f.source, newer, newer)
|
|
49
|
+
await startup(f.root)
|
|
50
|
+
assert.equal(readFileSync(f.main, 'utf8'), target('合法个人目标'))
|
|
51
|
+
assert.equal(readFileSync(`${f.main}.from-vp-account-6`, 'utf8'), target('账号旧目标'))
|
|
52
|
+
assert.equal(readFileSync(f.source, 'utf8'), target('账号旧目标'))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('repeated startup never replaces edited global data after old account files change', async (t) => {
|
|
56
|
+
const f = fixture(t)
|
|
57
|
+
await startup(f.root)
|
|
58
|
+
const marker = existsSync(f.marker) ? readFileSync(f.marker, 'utf8') : ''
|
|
59
|
+
writeFileSync(f.main, target('迁移后合法修订'))
|
|
60
|
+
writeFileSync(f.source, target('旧账号后来变化'))
|
|
61
|
+
const newer = new Date('2026-10-12T12:00:00Z')
|
|
62
|
+
utimesSync(f.source, newer, newer)
|
|
63
|
+
await startup(f.root)
|
|
64
|
+
assert.equal(readFileSync(f.main, 'utf8'), target('迁移后合法修订'))
|
|
65
|
+
assert.equal(readFileSync(f.marker, 'utf8'), marker)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('a newer global ledger is also finalized once rather than reconsidered on later account updates', async (t) => {
|
|
69
|
+
const f = fixture(t)
|
|
70
|
+
writeFileSync(f.main, target('已经较新的个人目标'))
|
|
71
|
+
const old = new Date('2026-10-10T12:00:00Z')
|
|
72
|
+
utimesSync(f.source, old, old)
|
|
73
|
+
await startup(f.root)
|
|
74
|
+
writeFileSync(f.source, target('再次变新的旧账号目标'))
|
|
75
|
+
const newer = new Date('2026-10-13T12:00:00Z')
|
|
76
|
+
utimesSync(f.source, newer, newer)
|
|
77
|
+
await startup(f.root)
|
|
78
|
+
assert.equal(readFileSync(f.main, 'utf8'), target('已经较新的个人目标'))
|
|
79
|
+
assert.equal(readFileSync(`${f.main}.from-vp-account-6`, 'utf8'), target('账号旧目标'))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('migration helper preserves existing conflict backups and safely finalizes unmarked accounts', async (t) => {
|
|
83
|
+
const { migratePersonalWorkspace } = await import('../lib/workspace-migration.js')
|
|
84
|
+
const f = fixture(t)
|
|
85
|
+
writeFileSync(f.main, target('合法个人目标'))
|
|
86
|
+
const backup = `${f.main}.from-vp-account-6`
|
|
87
|
+
writeFileSync(backup, target('更早的冲突副本'))
|
|
88
|
+
assert.deepEqual(migratePersonalWorkspace(f.root, 6), [])
|
|
89
|
+
assert.equal(readFileSync(f.main, 'utf8'), target('合法个人目标'))
|
|
90
|
+
assert.equal(readFileSync(backup, 'utf8'), target('更早的冲突副本'))
|
|
91
|
+
assert.equal(readFileSync(f.source, 'utf8'), target('账号旧目标'))
|
|
92
|
+
const marker = JSON.parse(readFileSync(f.marker, 'utf8'))
|
|
93
|
+
assert.equal(marker.files['TARGET.yaml'], 'conflict-preserved')
|
|
94
|
+
writeFileSync(join(f.accountDir, 'PROGRESS.yaml'), 'version: 1\nnodes: {}\n')
|
|
95
|
+
assert.deepEqual(migratePersonalWorkspace(f.root, 6), [])
|
|
96
|
+
assert.equal(existsSync(join(f.root, 'PROGRESS.yaml')), false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('migration helper validates account paths and skips absent accounts', async (t) => {
|
|
100
|
+
const { migratePersonalWorkspace } = await import('../lib/workspace-migration.js')
|
|
101
|
+
const f = fixture(t)
|
|
102
|
+
for (const accountId of ['../other', '-1', '6/subdir', true]) assert.throws(() => migratePersonalWorkspace(f.root, accountId), /账号 id/)
|
|
103
|
+
assert.deepEqual(migratePersonalWorkspace(f.root, 0), [])
|
|
104
|
+
assert.deepEqual(migratePersonalWorkspace(f.root, 7), [])
|
|
105
|
+
assert.equal(existsSync(f.marker), false)
|
|
106
|
+
})
|
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/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/vp-client-check.mjs 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 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 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
|
|
@@ -93,7 +93,7 @@ echo '· 全部副本哈希一致'
|
|
|
93
93
|
# 病因已修(那几节改跑临时副本),这条是**兜底闸门**:自检跑完比一次指纹,
|
|
94
94
|
# 一变就报错退出。这种事不靠自觉,靠闸门。
|
|
95
95
|
LEDGER_DIR="$DSH_HOME_BASH/knowledge"
|
|
96
|
-
LEDGERS='PROGRESS.yaml SCHEDULE.yaml CURRICULUM.yaml'
|
|
96
|
+
LEDGERS='PROGRESS.yaml SCHEDULE.yaml CURRICULUM.yaml TARGET.yaml VP_EVENTS.yaml'
|
|
97
97
|
ledger_fp() { (cd "$LEDGER_DIR" 2>/dev/null && sha256sum $LEDGERS 2>/dev/null || true); }
|
|
98
98
|
LEDGER_BEFORE=$(ledger_fp)
|
|
99
99
|
|
|
@@ -118,6 +118,11 @@ echo '── 编排证据验收(目录、引用、事件身份与重复记录
|
|
|
118
118
|
node --test "$SRC_BASH/dev/curriculum-evidence-check.mjs" || EVIDENCE_RC=$?
|
|
119
119
|
EVIDENCE_RC=${EVIDENCE_RC:-0}
|
|
120
120
|
|
|
121
|
+
echo
|
|
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/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
|
|
124
|
+
CONTRACT_RC=${CONTRACT_RC:-0}
|
|
125
|
+
|
|
121
126
|
LEDGER_AFTER=$(ledger_fp)
|
|
122
127
|
if [ "$LEDGER_BEFORE" != "$LEDGER_AFTER" ]; then
|
|
123
128
|
echo
|
|
@@ -149,4 +154,4 @@ if [ -n "$DSH_LIVE" ]; then
|
|
|
149
154
|
echo ' 要验「工具挂进教练的清单里」,得先重启 dsh。'
|
|
150
155
|
fi
|
|
151
156
|
|
|
152
|
-
exit $(( VERIFY_RC + SHAPE_RC + CURRICULUM_RC + EVIDENCE_RC ))
|
|
157
|
+
exit $(( VERIFY_RC + SHAPE_RC + CURRICULUM_RC + EVIDENCE_RC + CONTRACT_RC ))
|
package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 比赛依据与节点教学卡
|
|
2
|
+
|
|
3
|
+
训练路线先回答“目标比赛里要完成什么任务、本人被什么障碍挡住”,再选知识点。Rating 可以帮助找题池,不能证明某项能力已掌握,也不能单独决定下一阶段。
|
|
4
|
+
|
|
5
|
+
## 三种信息分别负责什么
|
|
6
|
+
|
|
7
|
+
| 资料 | 用途 | 不能据此声称什么 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `COMPETENCIES.yaml` | 九项通用比赛能力;关联精确 MAP 节点 | 它们不是官方考纲,教学验收阈值不是比赛规则 |
|
|
10
|
+
| `NODE_SCOPES.yaml` | 十四个常用节点的识别条件、证明问题、适用范围、反例、延伸与迁移动作 | 卡片不是完整教材,完成一张卡不等于永久掌握 |
|
|
11
|
+
| `CONTEST_PROFILES.yaml` | 有限真题样本支持的诊断任务与取舍提示 | 不代表专题频率、奖牌线或未来题目预测 |
|
|
12
|
+
|
|
13
|
+
`NODE_SCOPES` 的 `id` 必须与 MAP 节点完全一致。`recognition` 用于读题识别;`proofQuestions` 用于确认学生能解释;`notFor` 用于主动暴露边界;`transferProblems` 是练习任务描述,不是假造的题目编号;`exitEvidence` 是教学验收建议。来源证明技术条件,练习设计由插件负责。
|
|
14
|
+
|
|
15
|
+
三个 profile 当前均是 `provisional`:`regional-diagnostic`、`wf-extension`、`xian-pending`。这个状态表示可用于带限制的诊断,不能输出“已掌握本比赛考纲”。`reviewed` 也只能表示记录的样本已人工核验,不能去掉样本限制。
|
|
16
|
+
|
|
17
|
+
## 本轮采样范围
|
|
18
|
+
|
|
19
|
+
区域赛诊断取自 [NWERC 2024 官方题面](https://2024.nwerc.eu/main/problem-set.pdf) 的 A、D、E、F、K、L,与[官方解题](https://2024.nwerc.eu/main/solutions.pdf)对应章节核对。它们用来检查规则实现、状态设计、重复变换与多模型组合;没有采样全部题目,也没有跨年份计数。NWERC 与中国区域赛的差异保留为限制,不推导西安趋势。
|
|
20
|
+
|
|
21
|
+
WF 扩展诊断只读了 [2024 World Finals 官方题面](https://icpc.global/worldfinals/problems/2024-ICPC-World-Finals/icpc2024.pdf) 的 B、I、L,能力映射是教学推断。没有核验这些题的完整官方题解。交互程序的刷新与协议要求另依据[官方交互评测说明](https://docs.icpc.global/wp-content/uploads/2024/08/2024-JudgingNotes.pdf)。
|
|
22
|
+
|
|
23
|
+
[QOJ 西安目录](https://qoj.ac/category/87)只用于确认历年原题入口。当前没有完成原题、主办方题解、榜单的逐题核验,所以西安配置保持待采样,不生成专题权重。采用 [NCNA 公布的区域赛规则](https://nc.na.icpc.global/rules/)作通用赛制背景,也不能把它视作西安本届现场规则。
|
|
24
|
+
|
|
25
|
+
## 小鲸怎样使用
|
|
26
|
+
|
|
27
|
+
1. 先确认目标、剩余时间和个人/团队训练场景。选择 profile 时解释样本范围,不能从比赛名字推断考纲。
|
|
28
|
+
2. 用 VP 可观察事实形成诊断假设。失败原因经代码或复盘确认后,才称为短板;成功记录保留为能力基线。
|
|
29
|
+
3. 挑一个能完成验收的动作,说明它对应什么比赛任务、为何现在练、预计投入和改变安排的条件。
|
|
30
|
+
4. 讲知识点时先要求识别和证明,接着给边界反例,再找未标注专题的新题与隔天复测。来源跟随卡片一起展示。
|
|
31
|
+
|
|
32
|
+
用户临时问新知识点时可以回答。回答不自动成为主线任务,也不自动改变游标或阶段。
|
|
33
|
+
|
|
34
|
+
## 四个明确的教学边界
|
|
35
|
+
|
|
36
|
+
最短路卡提供有向边 `s→a:2`、`s→b:5`、`b→a:-4`:确定后不再更新的 Dijkstra 会过早确定 a;真实最短距离是 1。算法仍偶尔得到对的结果不能证明它适用于负权图。[技术依据](https://oi-wiki.org/graph/shortest-path/)
|
|
37
|
+
|
|
38
|
+
SCC 卡提供中心与三个叶子组成的双向星形图:四个点互达,但简单路径最多访问三个点。收集每个点必须保持简单路径时,不能把整个分量点权直接相加。[技术依据](https://oi-wiki.org/graph/scc/)
|
|
39
|
+
|
|
40
|
+
线段树卡对比 `[0,4]` 与 `[2,2]`:两段长度和区间和相同,各项与 2 取最小值后的区间和分别是 2 和 4。只维护和及长度不足以支持此更新,需要补信息或更换算法。[技术依据](https://oi-wiki.org/ds/seg/)
|
|
41
|
+
|
|
42
|
+
DP 卡要求证明状态足够。同一个位置如果对应不同剩余资源,而后续可选动作依赖资源,就不能直接合并这两段历史;这是状态设计问题,增加模板数量不能解决。[技术依据](https://oi-wiki.org/dp/basic/)
|
|
43
|
+
|
|
44
|
+
## 验证与范围
|
|
45
|
+
|
|
46
|
+
`node --test dev/catalog-check.mjs` 检查唯一 ID、真实 MAP 节点、能力映射、每张卡所需字段、公开来源链接、profile 状态与采样限制。该测试不向外网请求,也不读写用户训练账本。
|
|
47
|
+
|
|
48
|
+
集成回归已验证 `coach_scope` 返回节点卡、profile 可被目标选中且保留限制,以及两场测量从公开导入到阶段推进的闭环。目录校验不等于小鲸对话效果评测,也没有验证具体迁移练习的完整 AC 程序。
|
|
@@ -65,6 +65,10 @@
|
|
|
65
65
|
而是把相关事实摆到教练眼前(哪些节点放得最久、做过几题、独立几次),让它判断。
|
|
66
66
|
工具只拦**机械上做不到**的事(前置没满足、块和块叠在一起)。
|
|
67
67
|
|
|
68
|
+
目标权重与已确认事件可以产生参考排序,但必须带来源、时间窗口和未知部分;排序不
|
|
69
|
+
等于掌握度或教学命令。观察、待确认假设和已核实结论分开保存,不能从一次 WA 直接
|
|
70
|
+
推出“不会某个算法”,也不能把成功记录数量算成问题数量。
|
|
71
|
+
|
|
68
72
|
> 推论:**别指望工具把教练的活全干了。** 能机械检查的只是一部分,
|
|
69
73
|
> 剩下的写不进代码。(这条后来被证实了 —— 见原则 5 的边界。)
|
|
70
74
|
|
|
@@ -93,6 +97,9 @@ verified = 已验证 过了章节检测,有证据
|
|
|
93
97
|
把 learned 和 verified 合成一个「会/不会」,就等于退回原则 2 要治的那个病。
|
|
94
98
|
`verified` 因此**手标不了** —— 它不是靠自觉,是枚举里就没有这个选项。
|
|
95
99
|
|
|
100
|
+
verified 是有日期和条件的检测结论,不是永久标签。阶段验收另用 exitChecks 检查次数、
|
|
101
|
+
跨日、独立性与限时;陌生混合题与延迟复测用于核对迁移。旧证据不能复用成新阶段成果。
|
|
102
|
+
|
|
96
103
|
### 原则 4:单一真相源,视图必须生成
|
|
97
104
|
|
|
98
105
|
- 地图只有**一份**(YAML),进度只有**一份**
|
|
@@ -302,7 +309,7 @@ SCHEDULE.yaml 日程:忙事 + 排的块 + 实际用时
|
|
|
302
309
|
| ❌ 图当主要视图(Mermaid 那类) | 试过,效果差。**技能树是有向图,得专门画** |
|
|
303
310
|
| ❌ 推送作为唯一交付通道 | 死过一次:定时任务报成功,实际零接收 |
|
|
304
311
|
| ❌ 刷题数量当进度单位 | 单位错了,教练管知识点不管题数 |
|
|
305
|
-
| ❌
|
|
312
|
+
| ❌ 永久排除某个专题 | 专题取舍应有对应比赛样本、队伍分工与投入依据;未知先诊断,不因通用印象直接删掉几何等方向 |
|
|
306
313
|
| ❌ 再写一套「更聪明的」分析引擎 | 问题不在不够聪明,在分工反了 |
|
|
307
314
|
| ❌ 大而全的重构 | 大部分代码是**工具**(抓题面 / 检索 / 题库),那些有用 |
|
|
308
315
|
|
|
@@ -323,3 +330,12 @@ SCHEDULE.yaml 日程:忙事 + 排的块 + 实际用时
|
|
|
323
330
|
---
|
|
324
331
|
|
|
325
332
|
*方针变了改这里。实现细节看源码,使用说明看 README。*
|
|
333
|
+
|
|
334
|
+
## 八、比赛目标与知识图的关系
|
|
335
|
+
|
|
336
|
+
知识点是实现能力的工具。长期能力目标、本次比赛目标、当前阶段成果和今天的一个动作
|
|
337
|
+
必须连起来解释,不能把地图后继当作训练目的。近期计划按实际容量与新证据调整;远期
|
|
338
|
+
保留待定项,临时问答不改主线。比赛样本配置声明来源、采样范围和限制,不称官方考纲。
|
|
339
|
+
|
|
340
|
+
教学卡按精确节点提供识别条件、为何成立、失效反例、延伸和陌生题迁移。源资料支持
|
|
341
|
+
技术条件,具体练法由教练承担判断,不能用一份完整字段表证明教学已经有效。
|
|
@@ -61,7 +61,11 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
|
|
|
61
61
|
|
|
62
62
|
基础字段 `id/title/outcome/nodes/competencies/exitCriteria` 分别标识阶段、能力成果、地图节点、比赛能力和验收方法;一个阶段至少绑定一个真实地图节点或比赛能力,地图节点最多 50 个,整条路线 1–30 个阶段。读题、决策、团队沟通这类没有单一算法节点的阶段直接填 `competencies`。
|
|
63
63
|
|
|
64
|
-
VP
|
|
64
|
+
VP 事实引用包含内容指纹(如 `vp:event:problem:competency:hash`),进入课程时被快照。
|
|
65
|
+
未标能力的同步事实仍可引用,但不能凭空归为某项短板。原始 WA/未知独立性的 AC 和旧文字
|
|
66
|
+
复盘为 observation;明确独立成功为 baseline;复盘 findings=confirmed 引用本场未变化
|
|
67
|
+
事实后才产生能力 gap。hypothesis 和 resolved 不会制造新的成功事件,只有实际成功事实
|
|
68
|
+
能用于验收。程序检查引用存在与事件身份,因果解释仍由模型对照代码/回放核实。
|
|
65
69
|
|
|
66
70
|
还必须提供:
|
|
67
71
|
|
|
@@ -101,6 +105,22 @@ VP 题目里的 `competencies` 和复盘里的 `competencies` 会生成稳定证
|
|
|
101
105
|
|
|
102
106
|
## 执行、验收与调整
|
|
103
107
|
|
|
108
|
+
v3 的 training 阶段必须提供 `exitChecks`,覆盖所有节点和能力。每项且仅有 node 或
|
|
109
|
+
competency,并提供正整数 minEvents;可选 minDistinctDays(不大于 minEvents)、
|
|
110
|
+
independent、maxMinutes、metric 与 maxValue。metric/maxValue 必须成对且有真实数值。
|
|
111
|
+
文字 exitCriteria 描述能力成果,不能替代结构化条件。诊断、待定阶段不要求成功门槛。
|
|
112
|
+
|
|
113
|
+
`exitCheckResults` 返回 target、matchedEvents、distinctDays、passed 和 missing。
|
|
114
|
+
仅符合全部条件的 baseline 计数,按 eventId 去重;缺独立性、分钟数或数值指标不通过。
|
|
115
|
+
修改 exitChecks 会重置进入快照,不能降低或改写条件后复用此前成功。
|
|
116
|
+
`independent=true` 的事实来自明确记录;现有判卷记录缺此字段时不会自动补真,可用
|
|
117
|
+
coach_log 已支持的独立实训记录完成核验。
|
|
118
|
+
|
|
119
|
+
时间必须不早于阶段进入日、不晚于当前日。若有真实 `occurredAt`,还需在 enteredAt 至
|
|
120
|
+
now 之间;同步时间不能冒充发生时间。只有日期的旧记录由日期和进入快照约束,
|
|
121
|
+
`evidencePrecisionWarnings` 明示不能完整核验同日先后。分钟口径也需核对:practice 是
|
|
122
|
+
净做题用时;自动 VP 记录是开赛到该提交的累计分钟,不能混成同一种解题速度。
|
|
123
|
+
|
|
104
124
|
`focusNodes` 只允许来自当前阶段;为空时沿用该阶段全部节点。`focusUntil` 可留空,
|
|
105
125
|
有日期且已到期时先复盘。`weeklyHours=0` 表示尚未确认,不伪造每周可用时间。
|
|
106
126
|
有效课程的普通动作只能沿当前主线,`coach_assign` 带当前 `curriculumRevision`,
|
|
@@ -114,7 +134,7 @@ VP 题目里的 `competencies` 和复盘里的 `competencies` 会生成稳定证
|
|
|
114
134
|
进入阶段时保存 `enteredAt/entryEvidenceRefs/entryEvidenceEvents`。advance 必须同时满足:
|
|
115
135
|
|
|
116
136
|
* diagnostic:每个节点都有进入时未出现、日期不早于进入日的新有效观察。
|
|
117
|
-
* training:地图节点全部 verified
|
|
137
|
+
* training:地图节点全部 verified,所有 exitChecks 都有本阶段的新 baseline 事实满足;一次新 AC 不会代替次数、跨日、独立性或限时条件。
|
|
118
138
|
* 当前阶段没有待完成动作,也没有检测卷等待判定。
|
|
119
139
|
|
|
120
140
|
旧记录改内容或换来源不能替代新事件。诊断阶段不能通过 revise 改成训练或删除待诊断
|
|
@@ -129,12 +149,19 @@ VP 题目里的 `competencies` 和复盘里的 `competencies` 会生成稳定证
|
|
|
129
149
|
|
|
130
150
|
## 旧路线与边界
|
|
131
151
|
|
|
132
|
-
旧 v1 CURRICULUM 可读,显示 `needsReview
|
|
152
|
+
旧 v1/v2 CURRICULUM 可读,显示 `needsReview`,但不能照原有无依据或缺结构化验收的阶段继续布置或推进。
|
|
133
153
|
插件不会在读取时自动重写文件、补造依据或修改训练事实。先 assess,再 revise 为当前
|
|
134
|
-
|
|
154
|
+
及后续阶段补齐上述字段、证据和 exitChecks,明确保存为 v3;已完成阶段保留,不要求改写旧验收。
|
|
155
|
+
|
|
156
|
+
VP 通过已支持的只读连接与同步入口进入,原始记录不直接推定比赛短板。教学资料与比赛
|
|
157
|
+
配置来自包内有出处的目录;没有来源时返回未知或待采样,不编造本届考纲。
|
|
135
158
|
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
`assess evidenceEventIds=[eventId]` 先按场次筛选再截断200条,旧场次事实仍可引用。
|
|
160
|
+
`coach_vp_import` 的事件级 `measurements` 保存 `competency/metric/value/passed`,并可明确
|
|
161
|
+
`independent/occurredAt`;只有已验收且独立的测量成为 baseline,指标值仍需满足阶段 maxValue。
|
|
162
|
+
同一场次的多条事实仅算一场。小鲸需先核对回放,工具只验证字段与引用,不替它判断原因。
|
|
163
|
+
自动同步保留教练注解;显式导入更新整场时需保留其他题目。分页返回 `hasMore/pageCounts`,
|
|
164
|
+
累计缓存不代表本次新增。个人课程迁移只补缺失文件,冲突版本备份,不覆盖现有主目录账本。
|
|
138
165
|
|
|
139
166
|
## 开发验收
|
|
140
167
|
|