Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ jobs:
retention-days: 7

# The bench smoke: `workflow_dispatch` only, off by default, and informational (like `tools-trial`
# above). One platform is enough, because it exercises the harness rather than the platform — the
# above). Three host platforms exercise the portable harness; the
# `validate-posix` matrix is the gate, with `validate-windows` and `validate-macos-intel` as
# non-blocking counterparts. Both
# sides are the *same* staged worker, so the run
Expand All @@ -297,7 +297,20 @@ jobs:
# **never** run in CI.
bench-smoke:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.bench }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
target: linux-x64
worker: dsh-ops-worker
- os: windows-latest
target: win32-x64
worker: dsh-ops-worker.exe
- os: macos-latest
target: darwin-arm64
worker: dsh-ops-worker
runs-on: ${{ matrix.os }}
continue-on-error: true
timeout-minutes: 30
env:
Expand Down Expand Up @@ -342,7 +355,7 @@ jobs:
- name: Tool-level comparison, one binary on both sides
shell: bash
run: |
worker="runtime/linux-x64/dsh-ops-worker"
worker="runtime/${{ matrix.target }}/${{ matrix.worker }}"
mkdir -p bench/runs/smoke/fixtures
node bench/cli.mjs tools \
--baseline "$worker" --candidate "$worker" \
Expand All @@ -358,7 +371,7 @@ jobs:
- uses: actions/upload-artifact@v7
if: always()
with:
name: bench-smoke-report
name: bench-smoke-${{ matrix.target }}-report
path: |
bench/runs/smoke/**
bench/runs/*/run.json
Expand Down
8 changes: 8 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## 测试前的准备

Linux / macOS:使用当前平台构建的 worker;`setup --dry-run` 会说明跳过 Windows PowerShell pin、PSModulePath、Windows Bash IPC 和随包 Windows shell 检查的原因。当前 shell 行已支持各平台,不要再从早期 Windows-only 默认值推断配置。模型 sweep 仍需自己的 OpenAI 兼容服务与凭据;`tools` 自检不调用模型。

Linux / macOS: build the worker for the current host. `setup --dry-run` explains skipped Windows-only checks. `ab`, `compare`/`report`, `panel` and `tools` use the same entry point on every platform. A model sweep needs your own OpenAI-compatible endpoint; the tools self-check needs no model. Every sweep now fingerprints the composed profiles even without `--seal`, recording platform and component configuration in the arm summaries. `enabled` is a configuration fact, not proof of tool readiness; unresolved expressions remain `unknown`.

`compare` 默认拒绝不同平台(或已知/未知平台)混配,退出码为 2;诊断时可加 `--allow-cross-platform`,报告会明确标记差异不是插件效应。旧数据两侧都缺平台时仍可读取,显示 `unknown`,不能据此声称同平台。CI 的可选 tools-only smoke 覆盖 Linux / Windows / macOS,各自上传 artifact;不运行模型 sweep,不产出跨平台结论。

`compare` rejects mismatched platforms with exit 2. `--allow-cross-platform` is an explicit diagnostic override and adds a “not a plugin effect” warning. Both-unknown legacy archives remain readable. The optional, non-blocking tools smoke runs on Linux, Windows and macOS with separate artifacts.

```console
npm ci --ignore-scripts
npm run build && npm run build:ptc # lib/ + 原生 worker
Expand Down
17 changes: 14 additions & 3 deletions bench/compare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
percentChange,
} from './lib/compare-core.mjs'
import { RUNS_DIR } from './lib/next-folder.mjs'
import { environmentLines } from './lib/environment.mjs'

/** The sealed contract document inside a run directory. */
const SEALED_RUN = 'run.json'
Expand Down Expand Up @@ -78,6 +79,7 @@ const USAGE = `usage: node bench/compare.mjs <base> <cand> [options]
--noise-floor-pct N pin the A/A noise floor; without a floor every cost
direction is withheld (noise-floor-unknown)
--noise <a> <b> measure the A/A noise floor from two baseline runs
--allow-cross-platform diagnostics only: allow different platforms, with a warning
--json print the comparison document as JSON instead of the report
--out <file> where the rendered artifact goes (default: the sweep's group
folder, named "DSH-OPS Benchmark Report <UTC>.md" — a run with no
Expand Down Expand Up @@ -143,7 +145,10 @@ export function parseArgs(argv) {
if (index >= argv.length) throw new Error(`${name} needs a value`)
return argv[index]
}
if (name === '--json') {
if (name === '--allow-cross-platform') {
if (inline !== null) throw new Error('--allow-cross-platform takes no value')
options.allowCrossPlatform = true
} else if (name === '--json') {
if (inline !== null) throw new Error('--json takes no value; the comparison document goes to stdout and --out names a file')
options.json = true
} else if (name === '--aggregate') options.aggregate = take()
Expand Down Expand Up @@ -516,6 +521,7 @@ export function renderReport(comparison) {
const lines = [
REPORT_TITLE,
'',
...environmentLines(comparison.environment),
...taskSection(comparison),
'',
...toolSection(comparison),
Expand Down Expand Up @@ -625,14 +631,19 @@ export async function main(argv, io = {}) {
return EXIT.usage
}

const comparison = compareSummaries(base.doc, cand.doc, {
let comparison
try { comparison = compareSummaries(base.doc, cand.doc, {
allowCrossPlatform: options.allowCrossPlatform,
aggregate: options.aggregate,
tolerancePct: options.tolerancePct,
failOn: options.failOn,
regressionScope: options.regressionScope,
noiseFloorPct: options.noiseFloorPct ?? undefined,
noise: noise ?? undefined,
})
}) } catch (error) {
err(`compare: ${String(error?.message ?? error)}`)
return EXIT.usage
}
const payload = options.json
? `${JSON.stringify(comparison, null, 2)}\n`
: `${renderReport(comparison)}\n`
Expand Down
7 changes: 6 additions & 1 deletion bench/lib/compare-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
*/

import { CORE_BUCKETS, median, minValue, pairMetrics } from './aggregate.mjs'
import { compareEnvironment } from './environment.mjs'

export { median, minValue }

Expand Down Expand Up @@ -481,6 +482,7 @@ export function classifyExcluded(pairs, taskId = null) {
* @returns {object} the noise-floor document; `pct: null` when nothing could be paired.
*/
export function noiseFloor(baseA, baseB, options = {}) {
compareEnvironment(baseA, baseB, options)
const aggregate = AGGREGATES.includes(options.aggregate) ? options.aggregate : 'median'
const merged = pairMetrics(normalizeResults(baseA), normalizeResults(baseB))
const usablePairs = merged.pairs.filter((pair) => passed(pair.base) && passed(pair.cand))
Expand Down Expand Up @@ -547,7 +549,7 @@ export function resolveNoiseFloor(options = {}, base = null, cand = null) {
}
const noise = options.noise
if (noise && typeof noise === 'object' && !Array.isArray(noise) && (noise.base || noise.cand)) {
return noiseFloor(noise.base ?? base, noise.cand ?? base, { aggregate: options.aggregate })
return noiseFloor(noise.base ?? base, noise.cand ?? base, { aggregate: options.aggregate, allowCrossPlatform: options.allowCrossPlatform })
}
return {
format: NOISE_FORMAT,
Expand Down Expand Up @@ -702,6 +704,8 @@ export function formatNumber(value) {
* @returns {object} the comparison structure, including the gate and the verdict.
*/
export function compareSummaries(base, cand, options = {}) {
const environment = compareEnvironment(base, cand, options)
if (options.noise) compareEnvironment(base, options.noise.base, options)
const aggregate = AGGREGATES.includes(options.aggregate) ? options.aggregate : 'median'
const tolerancePct = isNumber(options.tolerancePct) ? options.tolerancePct : 2
const failOn = FAIL_ON.includes(options.failOn) ? options.failOn : 'both'
Expand Down Expand Up @@ -853,6 +857,7 @@ export function compareSummaries(base, cand, options = {}) {

return {
format: COMPARE_FORMAT,
environment,
base: { label: sideLabel(base, 'base'), profile: sideProfile(base, 'base'), model: baseModel, dshVersion: sideDshVersion(base, 'base') },
cand: { label: sideLabel(cand, 'cand'), profile: sideProfile(cand, 'cand'), model: candModel, dshVersion: sideDshVersion(cand, 'cand') },
aggregate,
Expand Down
49 changes: 49 additions & 0 deletions bench/lib/environment.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { parse } from 'yaml'

const COMPONENTS = ['shell', 'file', 'ptc', 'discovery', 'panel', 'tui', 'hide-official']

/** Read configuration facts, never evaluate an expression or infer publication readiness. */
export function componentFacts(text) {
const facts = Object.fromEntries(COMPONENTS.map(name => [name, 'unknown']))
let rows
try { rows = parse(text) } catch { return facts }
if (!Array.isArray(rows)) return facts
for (const name of COMPONENTS) {
const found = rows.filter(row => row?.id === `dsh-ops-${name}`)
if (!found.length) facts[name] = 'absent'
else if (found.length === 1) {
const row = found[0]
if (row.disabled === true || row.config?.enabled === false) facts[name] = 'disabled'
else if ((row.disabled === undefined || row.disabled === false) && (row.config?.enabled === undefined || row.config.enabled === true)) facts[name] = 'enabled'
}
}
return facts
}

export function platformOf(run) {
const value = run?.meta?.platform ?? run?.platform
return typeof value === 'string' && value.trim() ? value : 'unknown'
}

/** Old unknown/unknown archives remain readable; a known/unknown pair needs the override too. */
export function compareEnvironment(base, cand, options = {}) {
const platforms = { base: platformOf(base), cand: platformOf(cand) }
const crossPlatform = platforms.base !== platforms.cand
if (crossPlatform && options.allowCrossPlatform !== true) {
throw new Error(`platform mismatch: base=${platforms.base}, cand=${platforms.cand}; use --allow-cross-platform only for diagnostics (not a plugin effect)`)
}
return { platforms, crossPlatform, components: {
base: base?.components ?? base?.arms?.base?.components ?? null,
cand: cand?.components ?? cand?.arms?.cand?.components ?? null,
} }
}

export function environmentLines(environment) {
if (!environment) return []
return [
`Platform: base=${environment.platforms.base}; cand=${environment.platforms.cand}.`,
...(environment.crossPlatform ? ['WARNING: cross-platform diagnostic; differences are not a plugin effect.'] : []),
`Component rows (configuration, not a readiness receipt): base=${JSON.stringify(environment.components.base)}; cand=${JSON.stringify(environment.components.cand)}.`,
'',
]
}
4 changes: 4 additions & 0 deletions bench/report/panel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,10 @@ function renderConclusion() {
: '噪声底(A/A)±' + SUMMARY.noise.pct.toFixed(1) + '% → ' + (SUMMARY.readable ? '超过噪声 ⇒ 可读方向' : '落在噪声内 ⇒ 只报区间,不报方向')
}
function renderOverview(node) {
const environment = el('div', 'card')
environment.appendChild(el('h3', null, 'Platform / component configuration'))
environment.appendChild(el('pre', null, JSON.stringify({ platform: obj(RUN.meta).platform || 'unknown', components: Object.fromEntries(Object.entries(obj(RUN.arms)).map(([key, arm]) => [key, obj(arm).components || null])) }, null, 2)))
node.appendChild(environment)
const keys = pairKeys()
const taskRows = SUMMARY.tasks.map((task) => {
const base = task.arms[keys[0]]
Expand Down
8 changes: 5 additions & 3 deletions bench/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { RUNS_DIR } from './lib/next-folder.mjs'
import { DEFAULT_REPEAT, armOrder, createInterrupt, planPairs, planSessions, repeatsById, runSweep } from './run/schedule.mjs'
import { sealRun } from './run/seal.mjs'
import { renderReport, reportFileName } from './compare.mjs'
import { componentFacts } from './lib/environment.mjs'

/** The runner-only flags that take a value; `--task`/`--repeat`/`--folder`/`--base`/`--cand` are parsed by the CLI. */
const VALUE_FLAGS = ['--sleep-ms', '--out', '--heartbeat-ms', '--stall-ms', '--timeout', '--dsh', '--llm-patch', '--model']
Expand Down Expand Up @@ -233,7 +234,7 @@ export function dumpProfileConfig(entry, profile, context = {}) {
const detail = String(result.stderr ?? '').trim().split('\n').filter(Boolean).pop() ?? ''
throw new Error(`"dsh --profile ${profile} --dump-config" exited ${String(result.status)}${detail === '' ? '' : `: ${detail}`}`)
}
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`
return result.stdout ?? ''
}

/**
Expand All @@ -253,6 +254,7 @@ export function profileFingerprints(arms, context = {}) {
throw new Error(`"dsh --profile ${arm.profile} --dump-config" printed nothing, so the ${arm.side} arm has no profile fingerprint`)
}
hashes[arm.side] = createHash('sha256').update(text).digest('hex')
arm.components = componentFacts(text)
}
return hashes
}
Expand Down Expand Up @@ -478,12 +480,12 @@ export async function main(argv, io = {}) {
// fingerprint, and a sweep that cannot be sealed is not evidence. Refusing here costs
// nothing; refusing after the sweep would have spent the tokens first.
let hashes = io.profileHashes ?? null
if (hashes === null && seal !== undefined) {
if (hashes === null) {
try {
hashes = profileFingerprints(arms, { entry, dump: io.dumpProfileConfig, env: io.env })
} catch (error) {
err(`bench ab: ${String(error?.message ?? error)}`)
err('nothing was spawned and no token was spent; run "node bench/cli.mjs setup" if the profiles are not prepared yet, or pass --no-seal to sweep without sealing.')
err('nothing was spawned and no token was spent; run "node bench/cli.mjs setup" if the profiles are not prepared yet. Configuration facts are required even without --seal.')
return EXIT.failure
}
}
Expand Down
1 change: 1 addition & 0 deletions bench/run.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"$defs": {
"arm": { "description": "One arm of the comparison: what it was called, which profile contents it ran, and how it was launched.", "type": "object", "additionalProperties": false, "required": ["label", "profile", "profileHash"],
"properties": {
"components": { "description": "Component states read from composed configuration, not a publication/readiness receipt. Null for older or injected records.", "type": ["object", "null"], "additionalProperties": { "type": "string", "enum": ["enabled", "disabled", "absent", "unknown"] } },
"label": { "description": "Arm label, also the profile name it ran, e.g. bench-base.", "type": "string" },
"profile": { "description": "Profile name whose resolved configuration was measured.", "type": "string" },
"profileHash": { "description": "SHA-256 in hex of the arm's dumped configuration, the single-variable fingerprint.", "type": "string" },
Expand Down
4 changes: 4 additions & 0 deletions bench/run/schedule.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ function sessionOptions(arm, pair, spec, options, ctx) {
/** One arm's summary: the shape `bench/compare.mjs` reads as a side. */
function buildSummary(arm, rows, ctx) {
return {
meta: { platform: ctx.spec.meta?.platform ?? `${process.platform}-${process.arch}` },
profileHash: arm.profileHash ?? null,
components: arm.components ?? null,
format: SUMMARY_FORMAT,
label: arm.label,
profile: arm.profile,
Expand Down Expand Up @@ -237,6 +240,7 @@ function noiseFor(spec, summaries, arms) {
/** The arm descriptor the seal step (P8a) lifts into `run.json`'s `arms`. */
function descriptor(arm) {
return {
components: arm.components ?? null,
label: arm.label,
profile: arm.profile,
profileHash: arm.profileHash ?? null,
Expand Down
2 changes: 1 addition & 1 deletion bench/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function main(argv, io = {}) {
const out = io.out ?? ((line) => console.log(line))
const err = io.err ?? ((line) => console.error(line))
let options
try { options = parseArgs(argv, EXTRA_FLAGS) } catch (error) {
try { options = parseArgs(argv, { ...EXTRA_FLAGS, platform: io.platform }) } catch (error) {
err(`${error.message}\n\n${USAGE}`)
return EXIT.usage
}
Expand Down
7 changes: 5 additions & 2 deletions bench/setup/preflight.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -434,12 +434,15 @@ export function preflight(options, context = {}) {
else if (permissions.ok) log(` both sides will pin ${pairText(permissions.expected)}`)

log('\n— preflight: PSModulePath —')
const modulePath = inspectModulePath({ modulePath: env.PSModulePath, windows: env.SystemRoot, isDirectory: context.isDirectory })
outcomes.modulePath = modulePath ? 'poisoned' : 'clean'
const modulePath = platform === 'win32' ? inspectModulePath({ modulePath: env.PSModulePath, windows: env.SystemRoot, isDirectory: context.isDirectory }) : null
outcomes.modulePath = platform !== 'win32' ? 'skipped' : modulePath ? 'poisoned' : 'clean'
if (modulePath) {
log(` ✗ ${modulePath.entry} is ahead of the in-box 5.1 module directory`)
log(` search order: ${describeModulePath(env.PSModulePath, modulePath.entry)}`)
warnings.push(modulePath.warning)
} else if (platform !== 'win32') {
log(' skipped: Windows PSModulePath ordering does not apply on this platform')
log(' skipped: Windows Bash IPC proxy and bundled Windows shell payload checks do not apply')
} else {
log(' clean: no Core module root ahead of the in-box Windows PowerShell 5.1 directory')
}
Expand Down
13 changes: 8 additions & 5 deletions bench/setup/profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,13 @@ export function defaultShellPath() {

/** The defaults, before any flag is read.
* @returns {Options} the defaults. */
function defaultOptions() {
function defaultOptions(platform = process.platform) {
return {
baseProfile: 'bench-base', candProfile: 'bench-ops', template: 'headless',
// There is no Windows PowerShell to pin off Windows, and `dsh-pwsh-sandbox` is
// Windows-gated in the composition anyway, so the default is "no pin" there.
pinShell: process.platform === 'win32' ? defaultShellPath() : null,
pinShell: platform === 'win32' ? defaultShellPath() : null,
platform,
permissionMode: 'danger-full-access',
// The candidate measures the working tree: the default install is this checkout, so a
// sweep prepares itself from the code under edit without a publish or a version bump.
Expand All @@ -175,7 +176,7 @@ function defaultOptions() {
* @returns {Options} the options.
*/
export function parseArgs(argv, extra = {}) {
const options = defaultOptions()
const options = defaultOptions(extra.platform)
const values = { ...VALUE_FLAGS, ...extra.value }
const booleans = { ...BOOLEAN_FLAGS, ...extra.boolean }
for (let i = 0; i < argv.length; i += 1) {
Expand All @@ -200,7 +201,7 @@ export function parseArgs(argv, extra = {}) {
}
}
if (options.baseProfile === options.candProfile) throw new Error('--base-profile and --cand-profile must differ')
if (options.pinShell && process.platform === 'win32' && !path.isAbsolute(options.pinShell)) {
if (options.pinShell && options.platform === 'win32' && !path.isAbsolute(options.pinShell)) {
throw new Error(`--pin-shell must be an absolute path, got "${options.pinShell}"`)
}
if (!PERMISSION_MODES.includes(options.permissionMode)) {
Expand Down Expand Up @@ -590,7 +591,9 @@ function ensureProfile(name, context) {
*/
function pinBaselineShell(options, context) {
if (!options.pinShell) {
context.log(' --no-pin: nothing was touched. The baseline then hits the PATH pwsh alias, so both sides run PowerShell 7')
context.log(options.platform !== 'win32'
? ' skipped: Windows PowerShell pin is inapplicable on this platform; the host selects its configured shell'
: ' --no-pin: nothing was touched; the host selects its configured shell')
return 'skipped'
}
const file = path.join(context.home, 'profiles', options.baseProfile, 'cordis.patch.yml')
Expand Down
Loading
Loading