fm-bench 0.5.1 → 0.5.3

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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # fm-bench
2
2
 
3
+ [![CI](https://github.com/devinoldenburg/fm-bench/actions/workflows/ci.yml/badge.svg)](https://github.com/devinoldenburg/fm-bench/actions/workflows/ci.yml)
4
+
3
5
  Benchmark Apple's `fm` command on macOS 27+.
4
6
 
5
7
  Measure latency, throughput, streaming smoothness, stability, and goodput across Apple Foundation Models — with repeatable prompt suites and JSON/CSV reports for automation.
@@ -0,0 +1,35 @@
1
+ # Releasing
2
+
3
+ `fm-bench` uses semver tags (`v*.*.*`). Pushing a tag runs the **Release** workflow: test, lint, npm publish (with provenance), and GitHub release notes.
4
+
5
+ ## Prerequisites
6
+
7
+ - Repository secret **`NPM_TOKEN`**: npm automation token with publish access to `fm-bench`.
8
+ - **`main`** is green on CI.
9
+
10
+ ## Option A — GitHub Actions (recommended)
11
+
12
+ 1. Open **Actions → Version → Run workflow**.
13
+ 2. Choose `patch`, `minor`, `major`, or an exact semver.
14
+ 3. The job runs `npm version`, pushes the commit and tag to `main`.
15
+ 4. The tag push triggers **Release** automatically.
16
+
17
+ ## Option B — Local
18
+
19
+ ```sh
20
+ npm ci && npm test && npm run lint
21
+ npm version patch # or minor / major
22
+ git push --follow-tags
23
+ ```
24
+
25
+ ## Re-run Release without republishing
26
+
27
+ If npm already has the version but GitHub release failed (or vice versa), use **Actions → Release → Run workflow** and enter the existing tag (for example `v0.5.3`). The workflow skips npm publish when that version is already on the registry and skips creating a duplicate GitHub release.
28
+
29
+ ## Dry run
30
+
31
+ ```sh
32
+ npm run publish:dry-run
33
+ ```
34
+
35
+ CI runs the same dry-run on every push to `main` and on pull requests.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "fm-bench",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Dynamic benchmark CLI for Apple's fm command on macOS 27+.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "fm-bench": "./bin/fm-bench.js"
7
+ "fm-bench": "bin/fm-bench.js"
8
8
  },
9
9
  "files": [
10
10
  "bin",
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import { loadHistory, renderHistoryReport } from './history.js';
6
6
  import { runProcess } from './process.js';
7
7
  import { createProgress } from './progress.js';
8
8
  import { flattenResults, toCsv, writeReport } from './report.js';
9
+ import { parseBatteryOutput, parseThermalOutput } from './system.js';
9
10
  import { legendEntries, renderBenchmarkReport, renderLatencyHistogram, renderLegend, renderModelsTable } from './table.js';
10
11
 
11
12
  const require = createRequire(import.meta.url);
@@ -473,21 +474,23 @@ async function runDoctor(options) {
473
474
  }
474
475
 
475
476
  const thermalResult = await runProcess('pmset', ['-g', 'therm'], { timeoutMs: 5_000 });
476
- const thermalOut = (thermalResult.stdout || thermalResult.stderr || '').trim();
477
- const thermalMatch = thermalOut.match(/CPU_Scheduler_Limit\s*=\s*(\d+)/);
478
- if (thermalMatch) {
479
- const limit = Number.parseInt(thermalMatch[1], 10);
477
+ const thermal = parseThermalOutput(`${thermalResult.stdout || ''}${thermalResult.stderr || ''}`);
478
+ if (thermal.available && thermal.schedulerLimit != null) {
479
+ const limit = thermal.schedulerLimit;
480
480
  checks.push(['thermal limit', `${limit}%`, limit >= 100]);
481
+ } else if (thermal.available && thermal.healthyIdle) {
482
+ checks.push(['thermal', 'no thermal pressure', true]);
483
+ } else if (!thermal.available) {
484
+ checks.push(['thermal', 'unavailable', true]);
481
485
  }
482
486
 
483
487
  const batteryResult = await runProcess('pmset', ['-g', 'batt'], { timeoutMs: 5_000 });
484
- const batteryOut = (batteryResult.stdout || '').trim();
485
- const batteryLine = batteryOut.split('\n').find((line) => line.includes('%'));
486
- if (batteryLine) {
487
- const pctMatch = batteryLine.match(/(\d+)%/);
488
- const charging = /charging|AC Power|charged/i.test(batteryLine);
489
- const pct = pctMatch ? `${pctMatch[1]}%` : '?%';
490
- checks.push(['battery', `${pct}${charging ? ' (charging/AC)' : ' (battery)'}`, charging || Number.parseInt(pctMatch?.[1], 10) >= 20]);
488
+ const battery = parseBatteryOutput(`${batteryResult.stdout || ''}${batteryResult.stderr || ''}`);
489
+ if (battery.present) {
490
+ const pct = battery.pct != null ? `${battery.pct}%` : '?%';
491
+ const source = battery.onAC ? 'charging/AC' : 'battery';
492
+ const ok = battery.onAC || (battery.pct != null && battery.pct >= 20);
493
+ checks.push(['battery', `${pct} (${source})`, ok]);
491
494
  }
492
495
 
493
496
  const inspection = await inspectModels(options);
package/src/system.js ADDED
@@ -0,0 +1,65 @@
1
+ // Parsers for macOS system probes used by `fm-bench doctor`.
2
+ //
3
+ // macOS 27 changed the text emitted by `pmset -g therm` and `pmset -g batt`.
4
+ // These helpers accept the raw stdout/stderr of those commands and return
5
+ // structured values so the doctor command and its tests are not coupled to
6
+ // the exact wording of any one macOS release.
7
+
8
+ export function parseThermalOutput(output = '') {
9
+ const text = String(output).trim();
10
+ if (!text) {
11
+ return { available: false, schedulerLimit: null, raw: '' };
12
+ }
13
+
14
+ const limitMatch = text.match(/CPU_Scheduler_Limit\s*=\s*(-?\d+)/);
15
+ if (limitMatch) {
16
+ const schedulerLimit = Number.parseInt(limitMatch[1], 10);
17
+ return {
18
+ available: true,
19
+ schedulerLimit,
20
+ raw: text
21
+ };
22
+ }
23
+
24
+ // macOS 27 (and recent releases) prints these informational "Note:" lines
25
+ // when the system is NOT under thermal pressure. Treat that as a healthy,
26
+ // explicitly-reported state rather than silently dropping the check.
27
+ const hasNote = /^Note:/m.test(text);
28
+ if (hasNote) {
29
+ return {
30
+ available: true,
31
+ schedulerLimit: null,
32
+ raw: text,
33
+ healthyIdle: true
34
+ };
35
+ }
36
+
37
+ return { available: false, schedulerLimit: null, raw: text };
38
+ }
39
+
40
+ export function parseBatteryOutput(output = '') {
41
+ const text = String(output).trim();
42
+ if (!text) {
43
+ return { present: false, pct: null, onAC: false, raw: '' };
44
+ }
45
+
46
+ const lines = text.split(/\r?\n/);
47
+ const batteryLine = lines.find((line) => line.includes('%')) || '';
48
+ const pctMatch = batteryLine.match(/(\d+)%/);
49
+ const pct = pctMatch ? Number.parseInt(pctMatch[1], 10) : null;
50
+
51
+ // macOS 27 writes "Now drawing from 'AC Power'" on its own line and
52
+ // "AC attached" on the battery line. Older releases used "AC Power" in
53
+ // the battery line itself. Accept either form.
54
+ const onAC = /AC Power|AC attached|AC attached;/i.test(text)
55
+ || /;\s*charging\b/i.test(batteryLine);
56
+
57
+ const present = /InternalBattery|present:\s*true/i.test(text);
58
+
59
+ return {
60
+ present: present || pct != null,
61
+ pct,
62
+ onAC,
63
+ raw: text
64
+ };
65
+ }