catherd-cli 1.1.1 → 1.2.1

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.
Files changed (62) hide show
  1. package/README.md +40 -14
  2. package/catalog/ATTRIBUTION.md +36 -0
  3. package/catalog/models.json +30 -0
  4. package/catalog/scores.json +3006 -194
  5. package/catalog/sources.json +87 -0
  6. package/package.json +1 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +2 -2
  10. package/src/adapters/claude-code/models.ts +2 -1
  11. package/src/domain/bars.ts +119 -0
  12. package/src/domain/calibration.ts +125 -0
  13. package/src/domain/catalog.ts +210 -23
  14. package/src/domain/failover.ts +14 -1
  15. package/src/domain/profile-rules.ts +108 -18
  16. package/src/domain/sources.ts +144 -0
  17. package/src/entry/catalog-command.ts +163 -8
  18. package/src/entry/init-command.ts +72 -2
  19. package/src/entry/mcp/lane-tools.ts +1 -1
  20. package/src/entry/mcp/server.ts +13 -3
  21. package/src/entry/mcp/setup-tools.ts +24 -2
  22. package/src/entry/profile-command.ts +10 -4
  23. package/src/entry/tui/commands.ts +20 -2
  24. package/src/entry/tui/effects.ts +50 -1
  25. package/src/entry/tui/fixtures.ts +31 -3
  26. package/src/entry/tui/profile-edits.ts +17 -9
  27. package/src/entry/tui/profile-tree.ts +2 -2
  28. package/src/entry/tui/state.ts +4 -0
  29. package/src/entry/tui/views/profile-actions.ts +9 -1
  30. package/src/entry/tui/views/profiles.tsx +81 -14
  31. package/src/entry/tui/views/save-dialog.tsx +12 -4
  32. package/src/infra/env.ts +2 -0
  33. package/src/infra/jev-client.ts +88 -40
  34. package/src/infra/paths.ts +2 -0
  35. package/src/infra/sources/arena.ts +87 -0
  36. package/src/infra/sources/artificial-analysis.ts +112 -0
  37. package/src/infra/sources/cache.ts +124 -0
  38. package/src/infra/sources/csv.ts +38 -0
  39. package/src/infra/sources/epoch.ts +82 -0
  40. package/src/infra/sources/http.ts +75 -0
  41. package/src/infra/sources/litellm.ts +58 -0
  42. package/src/infra/sources/models-dev.ts +59 -0
  43. package/src/infra/sources/openrouter-endpoints.ts +52 -0
  44. package/src/infra/sources/openrouter-models.ts +60 -0
  45. package/src/infra/sources/rows.ts +38 -0
  46. package/src/infra/sources/vectara.ts +69 -0
  47. package/src/infra/sources/zip.ts +48 -0
  48. package/src/services/catalog-refresh.ts +200 -0
  49. package/src/services/catalog-service.ts +83 -23
  50. package/src/services/credentials.ts +69 -0
  51. package/src/services/doctor-sources.ts +76 -0
  52. package/src/services/doctor.ts +10 -0
  53. package/src/services/jev-service.ts +14 -41
  54. package/src/services/ports.ts +6 -0
  55. package/src/services/profile-service.ts +8 -6
  56. package/src/services/provenance.ts +115 -0
  57. package/src/services/routing-service.ts +7 -2
  58. package/src/services/run-evidence.ts +86 -0
  59. package/src/services/source-derive.ts +349 -0
  60. package/src/services/source-sync.ts +305 -0
  61. package/src/services/standins.ts +204 -0
  62. package/src/services/treat-likes.ts +84 -0
@@ -0,0 +1,87 @@
1
+ {
2
+ "schema": 1,
3
+ "version": "2026-09-28",
4
+ "sources": [
5
+ {
6
+ "id": "models-dev",
7
+ "name": "models.dev",
8
+ "url": "https://models.dev/api.json",
9
+ "license": "MIT",
10
+ "attribution": "Model facts from models.dev (MIT License), https://models.dev",
11
+ "keyed": false
12
+ },
13
+ {
14
+ "id": "openrouter-models",
15
+ "name": "OpenRouter models",
16
+ "url": "https://openrouter.ai/api/v1/models",
17
+ "license": "public API",
18
+ "attribution": "Model listings from the OpenRouter API, https://openrouter.ai",
19
+ "keyed": false
20
+ },
21
+ {
22
+ "id": "openrouter-endpoints",
23
+ "name": "OpenRouter endpoints",
24
+ "url": "https://openrouter.ai/api/v1/models",
25
+ "license": "public API",
26
+ "attribution": "Endpoint latency and throughput from the OpenRouter API, https://openrouter.ai",
27
+ "keyed": false
28
+ },
29
+ {
30
+ "id": "litellm",
31
+ "name": "LiteLLM",
32
+ "url": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
33
+ "license": "MIT",
34
+ "attribution": "Prices from LiteLLM's model_prices_and_context_window.json (MIT License), https://github.com/BerriAI/litellm",
35
+ "keyed": false
36
+ },
37
+ {
38
+ "id": "arena",
39
+ "name": "Arena (LMArena)",
40
+ "url": "https://huggingface.co/datasets/lmarena-ai/leaderboard-dataset",
41
+ "license": "CC-BY-4.0",
42
+ "attribution": "Leaderboard data by LMArena, CC BY 4.0, https://huggingface.co/datasets/lmarena-ai/leaderboard-dataset",
43
+ "keyed": false
44
+ },
45
+ {
46
+ "id": "vectara",
47
+ "name": "Vectara hallucination leaderboard",
48
+ "url": "https://github.com/vectara/hallucination-leaderboard",
49
+ "license": "Apache-2.0",
50
+ "attribution": "Hallucination Leaderboard by Vectara (Apache License 2.0), https://github.com/vectara/hallucination-leaderboard",
51
+ "keyed": false
52
+ },
53
+ {
54
+ "id": "epoch",
55
+ "name": "Epoch AI benchmarks",
56
+ "url": "https://epoch.ai/data/benchmark_data.zip",
57
+ "license": "CC-BY-4.0; external tables keep their own license",
58
+ "attribution": "Epoch AI, 'Capabilities & benchmarking'. Published online at epoch.ai. Retrieved from 'https://epoch.ai/benchmarks' (CC BY 4.0)",
59
+ "keyed": false
60
+ },
61
+ {
62
+ "id": "artificial-analysis",
63
+ "name": "Artificial Analysis",
64
+ "url": "https://artificialanalysis.ai/api/v2/data/llms/models",
65
+ "license": "free key, internal use, attribution; read with the user's own key and never shipped",
66
+ "attribution": "Data from Artificial Analysis, https://artificialanalysis.ai",
67
+ "keyed": true
68
+ }
69
+ ],
70
+ "aliases": {
71
+ "claude-4-5-haiku": "claude-haiku-4-5",
72
+ "claude-haiku-4-5-20251001": "claude-haiku-4-5"
73
+ },
74
+ "defaultEffort": {
75
+ "gpt-6-astra": "medium",
76
+ "gpt-6-sol": "medium",
77
+ "gpt-6-luna": "medium",
78
+ "gpt-5.6-sol": "medium",
79
+ "gpt-5.6-terra": "medium",
80
+ "gpt-5.6-luna": "medium",
81
+ "claude-fable-5-1": "high",
82
+ "claude-opus-5-5": "high",
83
+ "claude-sonnet-5-5": "high",
84
+ "claude-sonnet-5": "high",
85
+ "claude-haiku-4-5": "default"
86
+ }
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catherd-cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Herds coding agents: autopilot builds from your own Claude Code session.",
5
5
  "keywords": [
6
6
  "autopilot",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catherd",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Herds coding agents: Claude plans and verifies, Codex, opencode or headless Claude Code workers write the code, and Jev picks the model and effort for each piece.",
5
5
  "author": {
6
6
  "name": "47vigen",
@@ -1,7 +1,7 @@
1
1
  #!/bin/sh
2
2
  # catherd's MCP launcher (spec 1.1 §12): the plugin's .mcp.json runs it. The release stamps VERSION
3
3
  # (scripts/stamp-plugin-version.mjs), so the plugin always starts the server it was released with.
4
- VERSION="1.1.1"
4
+ VERSION="1.2.1"
5
5
 
6
6
  # a global catherd at this version (catherd init installs one): start it, no resolve at all
7
7
  if command -v catherd >/dev/null 2>&1 && [ "$(catherd --version 2>/dev/null)" = "$VERSION" ]; then
@@ -23,7 +23,7 @@ Your job:
23
23
 
24
24
  The catherd server keeps `state.md` true: it rewrites it on every dispatch, climb and landing.
25
25
 
26
- **Your first call is `status()`.** Its `version` must be the one this plugin pins, `catherd-cli@1.1.1`. If it differs, stop and tell the user to restart Claude Code so the plugin and its server match.
26
+ **Your first call is `status()`.** Its `version` must be the one this plugin pins, `catherd-cli@1.2.1`. If it differs, stop and tell the user to restart Claude Code so the plugin and its server match.
27
27
 
28
28
  **You never edit product files.** Every line of code, tests or docs comes from a role, including a one-line fix.
29
29
 
@@ -274,7 +274,7 @@ The brief is the `brief` text you pass to `dispatch` (catherd writes it to the d
274
274
  4. For a worker:
275
275
  - "Read `<R>/lanes/Mx.Ly.md`": decisions, signatures, data shapes;
276
276
  - its fast check (targeted tests, lint and type check), to run until it passes;
277
- - "Run the full suite only if this brief says so", and "Wrap any full build or full test suite in `bunx catherd-cli@1.1.1 lock -- <command>`": other lanes share the machine.
277
+ - "Run the full suite only if this brief says so", and "Wrap any full build or full test suite in `bunx catherd-cli@1.2.1 lock -- <command>`": other lanes share the machine.
278
278
  5. For a reviewer: the A-lines and the changed files, with new files read in full. It reports every finding as `BLOCKER|BUG|NIT file:line — problem — fix`, covering:
279
279
  - unmet A-lines and edge cases;
280
280
  - code or abstractions nobody needs;
@@ -9,6 +9,7 @@ export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
9
9
  export const CLAUDE_MODELS: DiscoveredModel[] = [
10
10
  { id: "claude-fable-5-1", efforts: CLAUDE_EFFORTS, context: 1_000_000, imageIn: true },
11
11
  { id: "claude-opus-5-5", efforts: CLAUDE_EFFORTS, context: 1_000_000, imageIn: true },
12
+ { id: "claude-sonnet-5-5", efforts: CLAUDE_EFFORTS, context: 1_000_000, imageIn: true },
12
13
  { id: "claude-sonnet-5", efforts: CLAUDE_EFFORTS, context: 1_000_000, imageIn: true },
13
14
  { id: "claude-haiku-4-5-20251001", efforts: [], context: 200_000, imageIn: true },
14
15
  ];
@@ -20,7 +21,7 @@ export const CLAUDE_ALIASES: Record<string, string> = {
20
21
  opus: "claude-opus-5-5",
21
22
  opusplan: "claude-opus-5-5",
22
23
  default: "claude-opus-5-5",
23
- sonnet: "claude-sonnet-5",
24
+ sonnet: "claude-sonnet-5-5",
24
25
  haiku: "claude-haiku-4-5-20251001",
25
26
  "claude-haiku-4-5": "claude-haiku-4-5-20251001",
26
27
  };
@@ -0,0 +1,119 @@
1
+ import { type Dim, DIMS, type Score } from "./catalog.ts";
2
+ import { DIFFICULTIES, type Difficulty, KINDS, type Kind } from "./lane.ts";
3
+
4
+ /**
5
+ * Spec 1.2 §5.1: the dimensions each kind's default bars span, for Track A (`copy`, `build`) and Track B
6
+ * (`logic`, `hard`). `steer` has no default bar: it is shown, and a user may set one.
7
+ */
8
+ export const DEFAULT_BAR_DIMS: Record<Kind, { A: Dim[]; B: Dim[] }> = {
9
+ repo_code: { A: ["repo_code"], B: ["repo_code", "honesty", "agentic"] },
10
+ terminal: { A: ["terminal"], B: ["terminal", "honesty", "agentic"] },
11
+ ui: { A: ["frontend", "repo_code"], B: ["frontend", "repo_code", "honesty", "agentic"] },
12
+ prose: { A: ["repo_code"], B: ["repo_code", "honesty"] },
13
+ research: { A: ["repo_code"], B: ["repo_code", "honesty"] },
14
+ };
15
+
16
+ /** The dimensions a difficulty's default bar spans for a kind. */
17
+ export const barDimsOf = (kind: Kind, d: Difficulty): Dim[] =>
18
+ d === "copy" || d === "build" ? DEFAULT_BAR_DIMS[kind].A : DEFAULT_BAR_DIMS[kind].B;
19
+
20
+ /** Every dimension some default bar uses: the values an enabled rung needs (spec 1.2 §6.1). */
21
+ export const BAR_DIMS: Dim[] = DIMS.filter((d) =>
22
+ KINDS.some((k) => DIFFICULTIES.some((x) => barDimsOf(k, x).includes(d))),
23
+ );
24
+
25
+ /** Spec 1.2 §5.2: the percentile of the scored rungs each difficulty's threshold sits at. */
26
+ export const BAR_PERCENTILE: Record<Difficulty, number> = { copy: 25, build: 50, logic: 60, hard: 75 };
27
+
28
+ const PERCENTILE_WORD: Record<Difficulty, string> = {
29
+ copy: "the 25th percentile",
30
+ build: "the median",
31
+ logic: "the 60th percentile",
32
+ hard: "the 75th percentile",
33
+ };
34
+
35
+ /** The `p`th percentile of `xs`, interpolated linearly between the closest ranks; NaN for none. */
36
+ export function percentile(xs: readonly number[], p: number): number {
37
+ if (xs.length === 0) return Number.NaN;
38
+ const s = [...xs].sort((a, b) => a - b);
39
+ const pos = ((s.length - 1) * p) / 100;
40
+ const lo = Math.floor(pos);
41
+ const hi = Math.ceil(pos);
42
+ return (s[lo] as number) + ((s[hi] as number) - (s[lo] as number)) * (pos - lo);
43
+ }
44
+
45
+ /**
46
+ * Spec 1.2 §5.2 "measured or better": a value published for this exact rung in the anchor's unit. That is a
47
+ * `verified` or `measured` value, and a hand-typed `secondary` one (no `source`: the 1.0 word for a value
48
+ * published by someone other than the vendor, which is 1.2's `measured`). Plan 14 Ruling 1.
49
+ */
50
+ export const measuredOrBetter = (s: Score): boolean =>
51
+ s.confidence === "verified" ||
52
+ s.confidence === "measured" ||
53
+ (s.confidence === "secondary" && s.source === undefined);
54
+
55
+ /** Each dimension's values that bars are derived from: one per canonical rung, measured or better. */
56
+ export function barPool(scores: readonly Score[]): Record<Dim, number[]> {
57
+ const out = Object.fromEntries(DIMS.map((d) => [d, [] as number[]])) as Record<Dim, number[]>;
58
+ const seen = new Set<string>();
59
+ for (const s of scores) {
60
+ const key = `${s.rung} ${s.dim}`;
61
+ if (!measuredOrBetter(s) || seen.has(key)) continue;
62
+ seen.add(key);
63
+ out[s.dim].push(s.value);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** A bar value, kept to four significant digits (DeepSWE 60.95, an Arena rating 1668, agentic 0.08622). */
69
+ const tidy = (v: number): number => Number(v.toPrecision(4));
70
+
71
+ export type Bars = Record<Kind, Record<Difficulty, Partial<Record<Dim, number>>>>;
72
+ /** `scores.json` `barsWhy`: per dimension and difficulty, where its threshold came from. */
73
+ export type BarsWhy = Partial<Record<Dim, Partial<Record<Difficulty, string>>>>;
74
+
75
+ /**
76
+ * Spec 1.2 §5.1–§5.2: the default bars. Each dimension's threshold at a difficulty is that difficulty's
77
+ * percentile of the pool (`barPool`), the same in every kind whose bar spans the dimension. A dimension with
78
+ * no pooled value gets no threshold (and a `barsWhy` line saying so). `benchmark` names each dimension's unit
79
+ * and `date` the day the data was read.
80
+ */
81
+ export function deriveBars(
82
+ pool: Record<Dim, number[]>,
83
+ benchmark: (d: Dim) => string,
84
+ date: string,
85
+ ): { bars: Bars; barsWhy: BarsWhy } {
86
+ const value: Partial<Record<Dim, Partial<Record<Difficulty, number>>>> = {};
87
+ const barsWhy: BarsWhy = {};
88
+ for (const dim of BAR_DIMS)
89
+ for (const d of DIFFICULTIES) {
90
+ if (!KINDS.some((k) => barDimsOf(k, d).includes(dim))) continue;
91
+ const xs = pool[dim];
92
+ const why = (barsWhy[dim] ??= {});
93
+ if (xs.length === 0) {
94
+ why[d] = `no rung is measured or better on ${benchmark(dim)} (${date}): no threshold`;
95
+ continue;
96
+ }
97
+ const v = tidy(percentile(xs, BAR_PERCENTILE[d]));
98
+ (value[dim] ??= {})[d] = v;
99
+ why[d] =
100
+ `${PERCENTILE_WORD[d]} of ${xs.length} rungs measured or better on ${benchmark(dim)} (${date}): ${v}`;
101
+ }
102
+ const bars = Object.fromEntries(
103
+ KINDS.map((k) => [
104
+ k,
105
+ Object.fromEntries(
106
+ DIFFICULTIES.map((d) => [
107
+ d,
108
+ Object.fromEntries(
109
+ barDimsOf(k, d).flatMap((dim) => {
110
+ const v = value[dim]?.[d];
111
+ return v === undefined ? [] : [[dim, v]];
112
+ }),
113
+ ),
114
+ ]),
115
+ ),
116
+ ]),
117
+ ) as Bars;
118
+ return { bars, barsWhy };
119
+ }
@@ -0,0 +1,125 @@
1
+ import type { Dim } from "./catalog.ts";
2
+ import type { SourceId } from "./sources.ts";
3
+
4
+ /** Spec 1.2 §4.2: a source needs this many rungs shared with the anchor for a dimension. */
5
+ export const MIN_SHARED = 5;
6
+ /** Spec 1.2 §4.2: a fit below this R² is not used. */
7
+ export const MIN_R2 = 0.5;
8
+
9
+ /** One number a source parser returns: `field` of `source`, and the benchmark it names. */
10
+ export interface FieldRef {
11
+ source: SourceId;
12
+ field: string;
13
+ benchmark: string;
14
+ }
15
+
16
+ /**
17
+ * Spec 1.2 §4.1: each dimension's anchor, the unit its bars are in, and the sources calibrated onto it.
18
+ * `shipped` is the hand-typed value `catalog/scores.json` carries, published for that rung (every anchor is
19
+ * keyless, spec 1.2 §4.1).
20
+ */
21
+ export const DIM_SOURCES: Record<Dim, { anchor: FieldRef | "shipped"; others: FieldRef[] }> = {
22
+ repo_code: {
23
+ anchor: "shipped",
24
+ others: [
25
+ {
26
+ source: "artificial-analysis",
27
+ field: "livecodebench",
28
+ benchmark: "LiveCodeBench (Artificial Analysis)",
29
+ },
30
+ { source: "artificial-analysis", field: "scicode", benchmark: "SciCode (Artificial Analysis)" },
31
+ {
32
+ source: "artificial-analysis",
33
+ field: "artificial_analysis_coding_index",
34
+ benchmark: "Artificial Analysis Coding Index",
35
+ },
36
+ { source: "epoch", field: "frontiercode", benchmark: "FrontierCode (Epoch AI)" },
37
+ ],
38
+ },
39
+ // plan 14 Ruling C-2: the vendors' Terminal-Bench 4.0 values, not Epoch's Terminal-Bench 2.0, which shares
40
+ // no rung with them; Epoch is fitted onto them once it covers five of their rungs
41
+ terminal: {
42
+ anchor: "shipped",
43
+ others: [
44
+ { source: "epoch", field: "terminalbench", benchmark: "Terminal-Bench (Epoch AI)" },
45
+ {
46
+ source: "artificial-analysis",
47
+ field: "terminalbench_v2_1",
48
+ benchmark: "Terminal-Bench 2.1 (Artificial Analysis)",
49
+ },
50
+ ],
51
+ },
52
+ honesty: {
53
+ anchor: "shipped",
54
+ others: [
55
+ { source: "vectara", field: "factual_consistency", benchmark: "Vectara factual consistency rate" },
56
+ { source: "arena", field: "agent_tool_hallucination", benchmark: "Arena agent tool hallucination" },
57
+ ],
58
+ },
59
+ agentic: {
60
+ anchor: { source: "arena", field: "agent", benchmark: "Arena agent, net improvement" },
61
+ others: [
62
+ { source: "arena", field: "agent_task_outcome_explicit", benchmark: "Arena agent task outcome" },
63
+ { source: "arena", field: "agent_bash_recovery_steps", benchmark: "Arena agent bash recovery" },
64
+ { source: "artificial-analysis", field: "tau2", benchmark: "tau2-bench (Artificial Analysis)" },
65
+ ],
66
+ },
67
+ steer: {
68
+ anchor: { source: "arena", field: "agent_steerability", benchmark: "Arena agent steerability" },
69
+ others: [],
70
+ },
71
+ frontend: {
72
+ anchor: { source: "arena", field: "webdev", benchmark: "Arena WebDev rating" },
73
+ others: [{ source: "epoch", field: "webdev", benchmark: "WebDev Arena (Epoch AI)" }],
74
+ },
75
+ };
76
+
77
+ export interface LineFit {
78
+ a: number;
79
+ b: number;
80
+ r2: number;
81
+ n: number;
82
+ }
83
+
84
+ /** Least squares `anchor = a·x + b` over `[x, anchor]` pairs; null with fewer than 2 pairs or no spread in x. */
85
+ export function fitLine(pairs: readonly (readonly [number, number])[]): LineFit | null {
86
+ const n = pairs.length;
87
+ if (n < 2) return null;
88
+ const mx = pairs.reduce((s, [x]) => s + x, 0) / n;
89
+ const my = pairs.reduce((s, [, y]) => s + y, 0) / n;
90
+ let sxx = 0;
91
+ let sxy = 0;
92
+ let syy = 0;
93
+ for (const [x, y] of pairs) {
94
+ sxx += (x - mx) ** 2;
95
+ sxy += (x - mx) * (y - my);
96
+ syy += (y - my) ** 2;
97
+ }
98
+ if (sxx === 0) return null;
99
+ const a = sxy / sxx;
100
+ const b = my - a * mx;
101
+ const res = pairs.reduce((s, [x, y]) => s + (y - (a * x + b)) ** 2, 0);
102
+ return { a, b, r2: syy === 0 ? 0 : 1 - res / syy, n };
103
+ }
104
+
105
+ /**
106
+ * Spec 1.2 §4.2: the fit of `other` onto `anchor` over the keys both hold, and whether it may be used: at
107
+ * least MIN_SHARED shared rungs and an R² of at least MIN_R2. `why` says why not.
108
+ */
109
+ export function calibrate(
110
+ anchor: ReadonlyMap<string, number>,
111
+ other: ReadonlyMap<string, number>,
112
+ ): { fit: LineFit | null; n: number; used: boolean; why?: string } {
113
+ const pairs: [number, number][] = [];
114
+ for (const [k, x] of other) {
115
+ const y = anchor.get(k);
116
+ if (y !== undefined) pairs.push([x, y]);
117
+ }
118
+ const n = pairs.length;
119
+ if (n < MIN_SHARED)
120
+ return { fit: null, n, used: false, why: `${n} shared rungs; a fit needs ${MIN_SHARED}` };
121
+ const fit = fitLine(pairs);
122
+ if (!fit) return { fit: null, n, used: false, why: "the source gives every shared rung the same value" };
123
+ if (fit.r2 < MIN_R2) return { fit, n, used: false, why: `R² ${fit.r2.toFixed(2)} is below ${MIN_R2}` };
124
+ return { fit, n, used: true };
125
+ }