@tangle-network/charts 0.1.0 → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tangle Network
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -15,6 +15,14 @@ The library draws and never decides.
15
15
  Ranks, intervals, frontier membership, estimate labels and pass counts come from the producer: a board record, a VerticalBench report or an agent-eval lens.
16
16
  A figure function returns a `Refusal` in place of a figure it cannot draw honestly, for example a bar over too few units, an interval that names no method, or a frontier the producer did not compute.
17
17
 
18
+ The producer's `estimate` controls rate marks: `descriptive` draws a rate bar and may show a named Wilson score interval when that estimate meets the producer's unit threshold.
19
+ Task-clustered bootstrap intervals require a `bootstrap` estimate.
20
+ Intervals without support are marked as not drawn in the data table and note.
21
+ A missing, zero, or unproven cost prints `unknown`; zero passes print `no pass`.
22
+ Rank numbers require every row to carry both a rank and a `bootstrap` estimate.
23
+ Malformed rates, intervals, matrix counts, and breakdown counts return a refusal instead of a misleading mark.
24
+ An interval whisker is omitted when its bounds exclude the row's rate.
25
+
18
26
  It has no runtime dependencies and uses no DOM, so it runs in a Worker, a build step or a browser.
19
27
 
20
28
  ## Use
package/dist/index.d.ts CHANGED
@@ -194,12 +194,14 @@ declare function comparisonIntervals(rows: ComparisonRow[], setups: Setup[], o:
194
194
  * hollow muted circle, and an estimated cost is never a filled disc. An ink
195
195
  * line joins frontier points. Every point is labelled directly; a label that
196
196
  * collides everywhere becomes a number listed in the table. A setup with no
197
- * pass or an unknown cost is listed under the axis, never placed at an
198
- * origin a log axis does not have. On a phone the plot becomes a list sorted
199
- * by the measure, with the same point marks, a rate bar and the cost as text.
197
+ * pass, an unknown cost or too few units for a rate is listed under the axis,
198
+ * never placed: a log axis has no origin, and a point's height is a rate. On a
199
+ * phone the plot becomes a list in rate order, with the same point marks, a
200
+ * rate bar and the cost as text.
200
201
  *
201
- * Refuses: the whole figure when fewer than two setups have a cost per pass; the frontier line unless two or more rows carry `onFront: true` (it
202
- * never computes dominance itself).
202
+ * Refuses: the whole figure when fewer than two setups have both a cost per
203
+ * pass and enough units for a rate; the frontier line unless two or more
204
+ * plotted rows carry `onFront: true` (it never computes dominance itself).
203
205
  */
204
206
 
205
207
  interface CostFrontierOptions {
@@ -214,14 +216,22 @@ declare const figureCss: string;
214
216
  /** --chart-* resolved per theme, for documents without brand tokens: light, dark by preference or [data-theme="dark"], light in print. */
215
217
  declare const chartTokensCss: string;
216
218
 
219
+ /**
220
+ * The order rows are drawn in. Ranked rows keep their rank. Otherwise rows
221
+ * with enough units to draw a rate come first, highest rate first; rows with
222
+ * too few units follow, most units first, so a 1/1 row never heads the list.
223
+ */
224
+ declare function rateOrder<T extends Pick<RateRow, "rank" | "rate" | "estimate" | "attempts">>(rows: readonly T[]): T[];
225
+
217
226
  /**
218
227
  * `rankedRates`: which setup passes most, and how uncertain is each rate.
219
228
  *
220
229
  * One row per setup: rank (only when every row is ranked), label, an accent
221
- * bar on a shared 0–1 axis with the interval as an ink whisker, `passed/n`,
230
+ * bar on a shared 0–1 axis with an eligible interval as an ink whisker, `passed/n`,
222
231
  * cost per pass and median run. An App Grade grader adds a tier strip under
223
- * the bar with a tick at the pass tier. Ranked rows keep their rank order;
224
- * unranked rows sort by rate, and the finding says no comparison backs it.
232
+ * the bar with a tick at the pass tier. Bootstrap rows with ranks keep their order;
233
+ * unranked rows sort by rate, rows with too few units last, and the finding
234
+ * says no comparison backs the order.
225
235
  *
226
236
  * Refuses: a bar when the producer labels the estimate `none` or
227
237
  * `insufficient` (the row prints its count only); a whisker whose interval
@@ -278,4 +288,4 @@ interface TaskMatrixOptions {
278
288
  }
279
289
  declare function taskMatrix(tasks: string[], setupsIn: Setup[], cells: MatrixCell[], o?: TaskMatrixOptions): Figure | Refusal;
280
290
 
281
- export { type BreakdownInput, type BreakdownOptions, type ComparisonIntervalsOptions, type ComparisonRow, type CostBasis, type CostFrontierOptions, type Estimate, type Exclusion, type Figure, type Interval, type IntervalMethod, type MatrixCell, type RankedRatesOptions, type RateRow, type Refusal, type Setup, type TaskMatrixOptions, type Tier, type TierCounts, breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, isRefusal, rankedRates, renderFigure, renderNotes, renderRefusals, taskMatrix };
291
+ export { type BreakdownInput, type BreakdownOptions, type ComparisonIntervalsOptions, type ComparisonRow, type CostBasis, type CostFrontierOptions, type Estimate, type Exclusion, type Figure, type Interval, type IntervalMethod, type MatrixCell, type RankedRatesOptions, type RateRow, type Refusal, type Setup, type TaskMatrixOptions, type Tier, type TierCounts, breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, isRefusal, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, taskMatrix };
package/dist/index.js CHANGED
@@ -367,16 +367,22 @@ function breakdown(input, o) {
367
367
  const id = o.id ?? "breakdown";
368
368
  const { reasons, setups } = input;
369
369
  if (reasons.length === 0 || setups.length === 0) return { id, refused: `No ${o.measure} to break down.` };
370
+ for (const setup of setups) {
371
+ if (setup.n !== null && (!Number.isInteger(setup.n) || setup.n < 0)) return { id, refused: `${setup.label}: invalid denominator.` };
372
+ }
370
373
  const count = /* @__PURE__ */ new Map();
371
374
  for (const c of input.counts) {
372
375
  if (!reasons.includes(c.reason) || !setups.some((s) => s.id === c.setup)) {
373
376
  return { id, refused: `A count names ${c.reason} \xD7 ${c.setup}, which is not in the reason and setup lists.` };
374
377
  }
378
+ const key = `${c.reason}\0${c.setup}`;
379
+ if (count.has(key)) return { id, refused: `Two counts for ${c.reason} \xD7 ${c.setup}.` };
380
+ if (c.count !== null && (!Number.isInteger(c.count) || c.count < 0)) return { id, refused: `${c.reason} \xD7 ${c.setup}: invalid count.` };
375
381
  const n = setups.find((s) => s.id === c.setup).n;
376
382
  if (c.count !== null && n !== null && c.count > n) {
377
383
  return { id, refused: `${c.reason} \xD7 ${c.setup}: ${c.count} exceeds the column's ${n} attempts.` };
378
384
  }
379
- count.set(`${c.reason}\0${c.setup}`, c.count);
385
+ count.set(key, c.count);
380
386
  }
381
387
  const get = (reason, setup) => count.get(`${reason}\0${setup}`) ?? null;
382
388
  const withN = setups.filter((s) => s.n !== null);
@@ -487,6 +493,9 @@ function intervalGap(interval) {
487
493
  if (!interval) return "no interval";
488
494
  const missing = [interval.level === null ? "level" : "", interval.method === null ? "method" : ""].filter(Boolean);
489
495
  if (missing.length) return `the interval names no ${missing.join(" or ")}`;
496
+ if (!(interval.method in METHOD_NAME)) return "the interval method is unknown";
497
+ if (!Number.isFinite(interval.level) || interval.level <= 0 || interval.level >= 1) return "the interval level is outside 0\u20131";
498
+ if (!Number.isFinite(interval.lower) || !Number.isFinite(interval.upper)) return "the interval bounds are not finite";
490
499
  if (!(interval.lower <= interval.upper)) return "the interval bounds are out of order";
491
500
  return null;
492
501
  }
@@ -498,9 +507,47 @@ function estimateNote(estimate, units) {
498
507
  if (estimate === "insufficient") return "too few units";
499
508
  return "";
500
509
  }
510
+ function perPass(cost) {
511
+ return cost.basis !== "unknown" && cost.perSolvedUsd !== null && Number.isFinite(cost.perSolvedUsd) && cost.perSolvedUsd > 0 ? cost.perSolvedUsd : null;
512
+ }
501
513
  function costText(cost, solved) {
502
- if (cost.perSolvedUsd === null) return solved === 0 ? "no pass" : "unknown";
503
- return cost.basis === "receipts" ? usd(cost.perSolvedUsd) : `${usd(cost.perSolvedUsd)} est.`;
514
+ if (solved === 0) return "no pass";
515
+ const value = perPass(cost);
516
+ if (value === null) return "unknown";
517
+ return cost.basis === "receipts" ? usd(value) : `${usd(value)} est.`;
518
+ }
519
+ function rateIntervalGap(row) {
520
+ if (!drawable(row.estimate)) return `${row.estimate} estimate`;
521
+ if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0\u20131";
522
+ const gap = intervalGap(row.interval);
523
+ if (gap) return gap;
524
+ if (row.interval.method === "mcnemar-exact") return "exact McNemar applies to paired comparisons, not rates";
525
+ if (row.estimate === "descriptive" && row.interval.method !== "wilson") {
526
+ return `descriptive estimate does not support ${intervalName(row.interval)}`;
527
+ }
528
+ if (row.interval.lower < 0 || row.interval.upper > 1) return "the rate interval is outside 0\u20131";
529
+ if (row.rate < row.interval.lower - 1e-6 || row.rate > row.interval.upper + 1e-6) {
530
+ return "the rate falls outside its interval";
531
+ }
532
+ return null;
533
+ }
534
+ function rateDataGap(row) {
535
+ if (!Number.isInteger(row.attempts) || row.attempts < 0 || !Number.isInteger(row.solved) || row.solved < 0 || row.solved > row.attempts) {
536
+ return "passed and attempt counts are invalid";
537
+ }
538
+ if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0\u20131";
539
+ if (row.attempts > 0 && Math.abs(row.rate - row.solved / row.attempts) > 1e-6) return "rate disagrees with passed/attempts";
540
+ if (row.attempts === 0 && row.rate !== 0) return "rate has no attempts";
541
+ if (drawable(row.estimate) && row.attempts === 0) return "drawable estimate has no attempts";
542
+ return null;
543
+ }
544
+ function rateOrder(rows) {
545
+ if (rows.length > 0 && rows.every((row) => row.rank !== null && row.estimate === "bootstrap")) {
546
+ return [...rows].sort((a, b) => a.rank - b.rank);
547
+ }
548
+ const drawn = rows.filter((row) => drawable(row.estimate)).sort((a, b) => b.rate - a.rate);
549
+ const counted = rows.filter((row) => !drawable(row.estimate)).sort((a, b) => b.attempts - a.attempts);
550
+ return [...drawn, ...counted];
504
551
  }
505
552
  function tierSummary(tiers, unmeasured = 0, flagged = 0) {
506
553
  const parts = tiers ? TIERS_BEST_FIRST.filter((t) => tiers[t] > 0).map((t) => `${tiers[t]}${t}`) : [];
@@ -575,8 +622,14 @@ function gaps(row) {
575
622
  const out = [];
576
623
  const gap = intervalGap(row.interval);
577
624
  if (gap) out.push(gap);
625
+ if (row.exactInterval !== null) {
626
+ const exactGap = intervalGap(row.exactInterval);
627
+ if (exactGap) out.push(`exact interval: ${exactGap}`);
628
+ }
578
629
  if (row.pairs === null) out.push("no pair count");
630
+ else if (!Number.isInteger(row.pairs) || row.pairs <= 0) out.push("invalid pair count");
579
631
  if (row.minimumEffect === null) out.push("no registered minimum effect");
632
+ else if (!Number.isFinite(row.minimumEffect)) out.push("invalid registered minimum effect");
580
633
  return out;
581
634
  }
582
635
  function comparisonIntervals(rows, setups, o) {
@@ -594,7 +647,7 @@ function comparisonIntervals(rows, setups, o) {
594
647
  if (drawn.length === 0) {
595
648
  return {
596
649
  id,
597
- refused: rows.every((r) => r.interval === null) ? "No comparison carries an interval." : `No comparison can be drawn. ${refused.join("; ")}.`
650
+ refused: rows.every((r) => r.interval === null) ? `${rows.length} ${plural(rows.length, "comparison")} listed; none carries an interval.` : `No comparison can be drawn. ${refused.join("; ")}.`
598
651
  };
599
652
  }
600
653
  const clears = drawn.filter(
@@ -615,20 +668,20 @@ function comparisonIntervals(rows, setups, o) {
615
668
  { label: "Interval", numeric: true },
616
669
  { label: "Exact interval", numeric: true }
617
670
  ];
618
- const fmt = (i) => i && intervalGap(i) === null ? `${pts(i.lower)} to ${pts(i.upper)} (${intervalName(i)})` : i ? `${pts(i.lower)} to ${pts(i.upper)} (${intervalGap(i)})` : "none";
671
+ const fmt = (i, n) => i && intervalGap(i) === null ? `${pts(i.lower)} to ${pts(i.upper)} (${intervalName(i)}, n=${n})` : i ? `not drawn (${intervalGap(i)})` : "none";
619
672
  const tableRows = rows.map((row) => [
620
673
  title(row),
621
674
  row.pairs === null ? "not recorded" : String(row.pairs),
622
675
  row.minimumEffect === null ? "none registered" : pts(row.minimumEffect),
623
- fmt(row.interval),
624
- fmt(row.exactInterval)
676
+ fmt(row.interval, row.pairs),
677
+ fmt(row.exactInterval, row.pairs)
625
678
  ]);
626
679
  const values = drawn.flatMap(({ row }) => [
627
680
  row.interval.lower,
628
681
  row.interval.upper,
629
682
  row.minimumEffect,
630
683
  0,
631
- ...row.exactInterval ? [row.exactInterval.lower, row.exactInterval.upper] : []
684
+ ...intervalGap(row.exactInterval) === null ? [row.exactInterval.lower, row.exactInterval.upper] : []
632
685
  ]);
633
686
  const span = Math.max(...values) - Math.min(...values) || 0.1;
634
687
  const domain = [Math.min(...values) - span * 0.08, Math.max(...values) + span * 0.08];
@@ -678,7 +731,7 @@ function draw(drawn, domain, width, plotX, labelW) {
678
731
  const me = x(row.minimumEffect);
679
732
  marks += line(me, barY - 8, me, barY + 20, "tgc-threshold");
680
733
  body += titled(
681
- `${title} \xB7 ${row.pairs} pairs \xB7 ${pts(row.interval.lower)} to ${pts(row.interval.upper)} \xB7 minimum effect ${pts(row.minimumEffect)}`,
734
+ `${title} \xB7 ${row.pairs} pairs \xB7 ${intervalName(row.interval)} interval ${pts(row.interval.lower)} to ${pts(row.interval.upper)}${rateExactTitle(row)} \xB7 minimum effect ${pts(row.minimumEffect)}`,
682
735
  marks
683
736
  );
684
737
  }
@@ -689,57 +742,94 @@ function draw(drawn, domain, width, plotX, labelW) {
689
742
  }
690
743
  return svgRoot(width, y + 24, axis + body);
691
744
  }
745
+ function rateExactTitle(row) {
746
+ return intervalGap(row.exactInterval) === null ? ` \xB7 ${intervalName(row.exactInterval)} exact interval ${pts(row.exactInterval.lower)} to ${pts(row.exactInterval.upper)}` : "";
747
+ }
692
748
 
693
749
  // src/cost-frontier.ts
694
750
  var overlaps = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
751
+ function crosses(box, a, b) {
752
+ const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
753
+ let t0 = 0;
754
+ let t1 = 1;
755
+ for (const [p, q] of [
756
+ [-dx, a[0] - box.x0],
757
+ [dx, box.x1 - a[0]],
758
+ [-dy, a[1] - box.y0],
759
+ [dy, box.y1 - a[1]]
760
+ ]) {
761
+ if (p === 0) {
762
+ if (q < 0) return false;
763
+ continue;
764
+ }
765
+ const t = q / p;
766
+ if (p < 0) t0 = Math.max(t0, t);
767
+ else t1 = Math.min(t1, t);
768
+ if (t0 > t1) return false;
769
+ }
770
+ return true;
771
+ }
772
+ var plottable = (row) => row.solved > 0 && perPass(row.cost) !== null && drawable(row.estimate);
773
+ function unplacedReason(row) {
774
+ if (row.solved === 0) return "0 passed";
775
+ if (!drawable(row.estimate)) return estimateNote(row.estimate, row.attempts);
776
+ return "cost unknown";
777
+ }
695
778
  function costFrontier(rows, o) {
696
779
  const id = o.id ?? "cost-frontier";
697
- const costed = rows.filter((row) => row.cost.perSolvedUsd !== null && row.cost.perSolvedUsd > 0);
698
- const unplaced = rows.filter((row) => !costed.includes(row));
699
- if (costed.length === 0) return { id, refused: `No setup has a cost per pass, so cost is not drawn.` };
780
+ for (const row of rows) {
781
+ const gap = rateDataGap(row);
782
+ if (gap) return { id, refused: `${row.label}: ${gap}.` };
783
+ }
784
+ const costed = rows.filter(plottable);
785
+ const unplaced = rateOrder(rows.filter((row) => !plottable(row)));
786
+ if (!rows.some((row) => row.solved > 0 && perPass(row.cost) !== null)) return { id, refused: `No setup has a cost per pass, so cost is not drawn.` };
700
787
  if (costed.length < 2) {
701
788
  return {
702
789
  id,
703
- refused: `Fewer than two setups have a cost per pass (${costed.length} of ${rows.length}); ${unplacedText(unplaced)}.`
790
+ refused: `Fewer than two setups have both a cost per pass and enough units for a rate (${costed.length} of ${rows.length}); ${unplacedText(unplaced)}.`
704
791
  };
705
792
  }
706
793
  const front = costed.filter((row) => row.onFront === true);
707
794
  const drawLine = front.length >= 2;
708
- const cheapestFront = [...front].sort((a, b) => a.cost.perSolvedUsd - b.cost.perSolvedUsd)[0];
709
- const top = rows.every((r) => r.rank !== null) ? [...rows].sort((a, b) => a.rank - b.rank)[0] : [...rows].sort((a, b) => b.rate - a.rate)[0];
795
+ const cost = (row) => perPass(row.cost);
796
+ const cheapestFront = [...front].sort((a, b) => cost(a) - cost(b))[0];
797
+ const top = rateOrder(rows)[0];
710
798
  const est = (row) => row.cost.basis === "receipts" ? "" : " (estimated)";
711
799
  let finding;
712
- if (cheapestFront && top.cost.perSolvedUsd !== null && cheapestFront.id !== top.id) {
713
- const saving = 1 - cheapestFront.cost.perSolvedUsd / top.cost.perSolvedUsd;
714
- finding = `${cheapestFront.label} costs ${usd(cheapestFront.cost.perSolvedUsd)} per pass${est(cheapestFront)}, ${pct(saving)} less than ${top.label}.`;
800
+ if (cheapestFront && costed.includes(top) && cheapestFront.id !== top.id) {
801
+ const saving = 1 - cost(cheapestFront) / cost(top);
802
+ finding = `${cheapestFront.label} costs ${usd(cost(cheapestFront))} per pass${est(cheapestFront)}, ${pct(saving)} less than ${top.label}.`;
715
803
  } else if (cheapestFront && cheapestFront.id === top.id) {
716
804
  finding = `${top.label} leads on both ${o.measure} and cost per pass.`;
717
805
  } else {
718
- const cheapest = [...costed].sort((a, b) => a.cost.perSolvedUsd - b.cost.perSolvedUsd)[0];
719
- finding = `${cheapest.label} has the lowest cost per pass, ${usd(cheapest.cost.perSolvedUsd)}${est(cheapest)}.`;
806
+ const cheapest = [...costed].sort((a, b) => cost(a) - cost(b))[0];
807
+ finding = `${cheapest.label} has the lowest cost per pass, ${usd(cost(cheapest))}${est(cheapest)}.`;
720
808
  }
721
- const lede = `${costed.length} of ${rows.length} setups have a cost per pass; ` + (drawLine ? `${front.length} sit on the producer's frontier.` : front.length === 1 ? "1 sits on the producer's frontier." : "the producer computed no frontier.");
722
- const read = `Up is a higher ${o.measure}; right is cheaper per pass.` + (drawLine ? " Filled points sit on the frontier; the line joins them." : "") + (costed.some((r) => r.cost.basis !== "receipts") ? " A hollow accent ring is a frontier point with an estimated cost." : "");
723
- const intervals = [...new Set(costed.filter((r) => intervalGap(r.interval) === null).map((r) => intervalName(r.interval)))];
809
+ const lede = `${costed.length} of ${rows.length} setups are plotted; ` + (drawLine ? `${front.length} sit on the producer's frontier.` : front.length === 1 ? "1 sits on the producer's frontier." : "the producer computed no frontier.");
810
+ const read = `Higher ${o.measure} and lower cost per pass are better; on the wide plot, better is up and right.` + (drawLine ? " Filled points sit on the frontier; the line joins them." : "") + (costed.some((r) => r.onFront && r.cost.basis !== "receipts") ? " A hollow accent ring is a frontier point with an estimated cost." : "");
811
+ const intervals = [...new Set(costed.filter((r) => rateIntervalGap(r) === null).map((r) => intervalName(r.interval)))];
724
812
  const bases = [...new Set(costed.map((r) => r.cost.basis))];
725
813
  const method = [
726
814
  `Cost per pass is model spend over passed attempts, ${bases.map((b) => b === "receipts" ? "from router receipts" : b === "estimated" ? "estimated, not billed" : "from an unrecorded source").join(" or ")}.`,
727
- "The frontier is the producer's: no setup off it is both cheaper and higher.",
815
+ front.length ? "The frontier is the producer's: no setup off it is both cheaper and higher." : "The producer did not compute a frontier.",
728
816
  intervals.length ? `Vertical lines: ${intervals.join(", ")} on the ${o.measure}.` : ""
729
817
  ].filter(Boolean).join(" ");
730
818
  const columns = [
731
819
  { label: "Setup" },
732
820
  { label: capitalize(o.measure), numeric: true },
733
821
  { label: "Passed", numeric: true },
822
+ { label: "Interval" },
734
823
  { label: "Cost per pass", numeric: true },
735
824
  { label: "Cost basis" },
736
825
  { label: "On frontier" }
737
826
  ];
738
- const sorted = [...rows].sort((a, b) => b.rate - a.rate);
827
+ const sorted = rateOrder(rows);
739
828
  const tableRows = sorted.map((row) => [
740
829
  row.label,
741
- pct(row.rate),
830
+ drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts),
742
831
  kn(row.solved, row.attempts),
832
+ rateIntervalGap(row) === null ? `${pct(row.interval.lower)}\u2013${pct(row.interval.upper)} (${intervalName(row.interval)}, n=${row.attempts})` : `not drawn (${rateIntervalGap(row)})`,
743
833
  costText(row.cost, row.solved),
744
834
  row.cost.basis,
745
835
  row.onFront === null ? "not computed" : row.onFront ? "yes" : "no"
@@ -760,7 +850,10 @@ function costFrontier(rows, o) {
760
850
  note: {
761
851
  method,
762
852
  n: `${rows.reduce((s, r) => s + r.attempts, 0)} attempts over ${rows.length} setups.`,
763
- exclusions: unplaced.length ? [`Not placed: ${unplacedText(unplaced)}.`] : []
853
+ exclusions: [
854
+ ...unplaced.length ? [`Not placed: ${unplacedText(unplaced)}.`] : [],
855
+ ...costed.filter((r) => rateIntervalGap(r) !== null).map((r) => `No interval for ${r.label}: ${rateIntervalGap(r)}.`)
856
+ ]
764
857
  }
765
858
  };
766
859
  }
@@ -769,7 +862,7 @@ function narrowList(rows) {
769
862
  let out = "";
770
863
  let y = 4;
771
864
  for (const [i, row] of rows.entries()) {
772
- const placed = row.cost.perSolvedUsd !== null && row.cost.perSolvedUsd > 0;
865
+ const placed = plottable(row);
773
866
  const label = wrap(row.label, NARROW - 16, 14);
774
867
  let marks = placed ? circle(4, y + 10, 4, row.onFront ? row.cost.basis === "receipts" ? "tgc-pt-front" : "tgc-pt-est" : "tgc-pt") : "";
775
868
  marks += textBlock(label.lines, 16, y + 14, 18, `class="tgc-ink" font-size="14"`);
@@ -777,7 +870,7 @@ function narrowList(rows) {
777
870
  if (drawable(row.estimate)) {
778
871
  let end = row.rate * x1;
779
872
  marks += hbar(0, cy, end, 10, "tgc-bar");
780
- if (intervalGap(row.interval) === null) {
873
+ if (rateIntervalGap(row) === null) {
781
874
  const lo = row.interval.lower * x1;
782
875
  const hi = row.interval.upper * x1;
783
876
  marks += line(lo, cy + 5, hi, cy + 5, "tgc-whisker") + line(lo, cy + 1, lo, cy + 9, "tgc-whisker") + line(hi, cy + 1, hi, cy + 9, "tgc-whisker");
@@ -787,10 +880,12 @@ function narrowList(rows) {
787
880
  cy += 16;
788
881
  }
789
882
  const count = `${kn(row.solved, row.attempts)} passed${drawable(row.estimate) ? "" : `, ${estimateNote(row.estimate, row.attempts)}`}`;
790
- const fact = placed ? `${count} \xB7 ${costText(row.cost, row.solved)} per pass${row.onFront ? " \xB7 on the frontier" : ""}` : `${count} \xB7 not placed`;
883
+ const reason = row.solved > 0 && drawable(row.estimate) ? "cost unknown \xB7 " : "";
884
+ const fact = placed ? `${count} \xB7 ${costText(row.cost, row.solved)} per pass${row.onFront ? " \xB7 on the frontier" : ""}` : `${count} \xB7 ${reason}not plotted`;
791
885
  const facts = fitLines(fact.split(" \xB7 "), NARROW, 12, "mono");
792
886
  marks += textBlock(facts, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
793
- out += titled(`${row.label} \xB7 ${fact}`, marks);
887
+ const interval = rateIntervalGap(row) === null ? ` \xB7 ${intervalName(row.interval)} interval ${pct(row.interval.lower)}\u2013${pct(row.interval.upper)}, n=${row.attempts}` : "";
888
+ out += titled(`${row.label} \xB7 ${fact}${interval}`, marks);
794
889
  y = cy + 4 + facts.length * 16;
795
890
  if (i < rows.length - 1) out += line(0, y + 2, NARROW, y + 2, "tgc-rule");
796
891
  y += 10;
@@ -804,19 +899,25 @@ function narrowList(rows) {
804
899
  return svgRoot(NARROW, y, out);
805
900
  }
806
901
  function unplacedText(rows) {
807
- return listJoin(rows.map((r) => `${r.label}, ${r.solved === 0 ? "0 passed" : "cost unknown"}`));
902
+ return listJoin(rows.map((r) => `${r.label} (${unplacedReason(r)})`));
808
903
  }
809
904
  function plot(costed, unplaced, drawLine, o) {
810
905
  const left = 48;
811
906
  const right = WIDE - 24;
812
907
  const top = 32;
813
908
  const bottom = 272;
814
- const costs = costed.map((r) => r.cost.perSolvedUsd);
909
+ const costs = costed.map((r) => perPass(r.cost));
815
910
  const [lo, hi] = logDomain(Math.min(...costs), Math.max(...costs));
816
911
  const x = log([lo, hi], [left, right].reverse());
817
- const y = (v) => bottom - v * (bottom - top);
912
+ const highest = Math.max(
913
+ ...costed.map((r) => Math.max(r.rate, rateIntervalGap(r) === null ? r.interval.upper : 0))
914
+ );
915
+ const yMax = Math.min(1, Math.max(0.2, Math.ceil(highest * 5 - 1e-9) / 5));
916
+ const yStep = yMax <= 0.4 ? 0.1 : 0.2;
917
+ const y = (v) => bottom - v / yMax * (bottom - top);
818
918
  let out = text(0, 14, capitalize(o.measure), { cls: "tgc-ink-muted" });
819
- for (const v of [0, 0.25, 0.5, 0.75, 1]) {
919
+ for (let i = 0; i <= Math.round(yMax / yStep); i++) {
920
+ const v = Number((i * yStep).toFixed(10));
820
921
  out += line(left, y(v), right, y(v), v === 0 ? "tgc-axis" : "tgc-gridline");
821
922
  out += text(left - 8, y(v) + 4, pct(v), { cls: "tgc-ink-muted tgc-num", anchor: "end" });
822
923
  }
@@ -830,22 +931,20 @@ function plot(costed, unplaced, drawLine, o) {
830
931
  });
831
932
  const placed = costed.map((row) => ({
832
933
  row,
833
- x: x(row.cost.perSolvedUsd),
934
+ x: x(perPass(row.cost)),
834
935
  y: y(row.rate),
835
936
  estimated: row.cost.basis !== "receipts"
836
937
  }));
837
- if (drawLine) {
838
- const pts2 = placed.filter((p) => p.row.onFront).sort((a, b) => a.x - b.x);
839
- out += polyline(pts2.map((p) => [p.x, p.y]), "tgc-front-line");
840
- }
938
+ const frontLine = drawLine ? placed.filter((p) => p.row.onFront).sort((a, b) => a.x - b.x).map((p) => [p.x, p.y]) : [];
939
+ if (drawLine) out += polyline(frontLine, "tgc-front-line");
841
940
  for (const p of placed) {
842
- if (intervalGap(p.row.interval) === null) {
941
+ if (rateIntervalGap(p.row) === null) {
843
942
  out += line(p.x, y(p.row.interval.lower), p.x, y(p.row.interval.upper), "tgc-pt-ci");
844
943
  }
845
944
  }
846
945
  const obstacles = placed.map((p) => ({ x0: p.x - 6, y0: p.y - 6, x1: p.x + 6, y1: p.y + 6 }));
847
946
  for (const p of placed) {
848
- if (intervalGap(p.row.interval) === null) {
947
+ if (rateIntervalGap(p.row) === null) {
849
948
  obstacles.push({ x0: p.x - 2, y0: y(p.row.interval.upper), x1: p.x + 2, y1: y(p.row.interval.lower) });
850
949
  }
851
950
  }
@@ -869,7 +968,8 @@ function plot(costed, unplaced, drawLine, o) {
869
968
  const x0 = anchor === "start" ? lx : anchor === "end" ? lx - w : lx - w / 2;
870
969
  const box = { x0, y0: ly - 11, x1: x0 + w, y1: ly + 3 };
871
970
  const inside = box.x0 >= bounds.x0 && box.x1 <= bounds.x1 && box.y0 >= bounds.y0 && box.y1 <= bounds.y1;
872
- if (!inside || obstacles.some((b) => overlaps(b, box))) continue;
971
+ const onLine = frontLine.some((a, i) => i > 0 && crosses(box, frontLine[i - 1], a));
972
+ if (!inside || onLine || obstacles.some((b) => overlaps(b, box))) continue;
873
973
  obstacles.push(box);
874
974
  if (leader) labels.push(line(p.x, p.y, anchor === "start" ? lx - 3 : lx + 3, ly - 4, "tgc-leader"));
875
975
  labels.push(text(lx, ly, p.row.label, { cls: "tgc-ink", anchor }));
@@ -885,7 +985,7 @@ function plot(costed, unplaced, drawLine, o) {
885
985
  for (const p of placed) {
886
986
  const cls = p.row.onFront ? p.estimated ? "tgc-pt-est" : "tgc-pt-front" : "tgc-pt";
887
987
  out += titled(
888
- `${p.row.label} \xB7 ${pct(p.row.rate)} ${o.measure} \xB7 ${costText(p.row.cost, p.row.solved)} per pass${p.row.onFront ? " \xB7 on the frontier" : ""}`,
988
+ `${p.row.label} \xB7 ${pct(p.row.rate)} ${o.measure} \xB7 ${kn(p.row.solved, p.row.attempts)} passed \xB7 ${costText(p.row.cost, p.row.solved)} per pass${rateIntervalGap(p.row) === null ? ` \xB7 ${intervalName(p.row.interval)} interval ${pct(p.row.interval.lower)}\u2013${pct(p.row.interval.upper)}, n=${p.row.attempts}` : ""}${p.row.onFront ? " \xB7 on the frontier" : ""}`,
889
989
  circle(p.x, p.y, 4, cls)
890
990
  );
891
991
  }
@@ -1207,36 +1307,44 @@ function rankedRates(input, o) {
1207
1307
  const noun = o.nouns ?? { attempt: "attempt", setup: "setup" };
1208
1308
  if (input.length === 0) return { id, refused: `No ${noun.setup} has an ${noun.attempt}.` };
1209
1309
  for (const row of input) {
1210
- const expected = row.attempts > 0 ? row.solved / row.attempts : 0;
1211
- if (row.solved > row.attempts || Math.abs(row.rate - expected) > 1e-6) {
1212
- return { id, refused: `${row.label}: rate ${row.rate} disagrees with ${kn(row.solved, row.attempts)} passed.` };
1310
+ const gap = rateDataGap(row);
1311
+ if (gap) return { id, refused: `${row.label}: ${gap}.` };
1312
+ if (row.tiers) {
1313
+ if (TIERS_BEST_FIRST.some((tier) => !Number.isInteger(row.tiers[tier]) || row.tiers[tier] < 0)) {
1314
+ return { id, refused: `${row.label}: tier counts must be nonnegative whole numbers.` };
1315
+ }
1316
+ if (tierTotal(row.tiers) > row.attempts) return { id, refused: `${row.label}: tier counts exceed attempts.` };
1317
+ if (o.passTier) {
1318
+ const passing = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + row.tiers[tier], 0);
1319
+ if (passing !== row.solved) return { id, refused: `${row.label}: ${row.solved} passes disagree with ${passing} grades at ${o.passTier} or better.` };
1320
+ }
1213
1321
  }
1214
1322
  }
1215
- const ranked = input.every((row) => row.rank !== null);
1216
- const rows = ranked ? [...input].sort((a, b) => a.rank - b.rank) : [...input].sort((a, b) => b.rate - a.rate);
1323
+ const ranked = input.every((row) => row.rank !== null && row.estimate === "bootstrap");
1324
+ const rows = rateOrder(input);
1217
1325
  const layout = {
1218
1326
  ranked,
1219
- cost: rows.some((r) => r.cost.perSolvedUsd !== null || r.cost.basis !== "unknown" || r.cost.receipts !== null),
1327
+ cost: rows.some((r) => perPass(r.cost) !== null || r.cost.basis !== "unknown" || r.cost.receipts !== null),
1220
1328
  median: rows.some((r) => r.medianWallMs !== null)
1221
1329
  };
1222
1330
  const bars = rows.filter((row) => drawable(row.estimate));
1223
1331
  const noBar = rows.filter((row) => !drawable(row.estimate));
1224
- const whiskers = bars.filter((row) => intervalGap(row.interval) === null);
1225
- const noWhisker = bars.filter((row) => intervalGap(row.interval) !== null);
1332
+ const whiskers = bars.filter((row) => rateIntervalGap(row) === null);
1333
+ const noWhisker = bars.filter((row) => rateIntervalGap(row) !== null);
1226
1334
  const tiered = rows.some((row) => row.tiers !== null);
1227
1335
  const passed = rows.reduce((sum, row) => sum + row.solved, 0);
1228
1336
  const attempts = rows.reduce((sum, row) => sum + row.attempts, 0);
1229
1337
  const setupsN = `${rows.length} ${plural(rows.length, noun.setup)}`;
1230
1338
  const top = rows[0];
1231
- const uniqueTop = rows.length === 1 || rows[1].rate < top.rate;
1232
- const finding = ranked ? `${top.label} ranks first, passing ${pct(top.rate)} of ${plural(2, noun.attempt)}.` : rows.length === 1 && drawable(top.estimate) ? `${top.label} passes ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}.` : drawable(top.estimate) && uniqueTop ? `${top.label} has the highest ${o.measure}, ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}; no comparison backs the order.` : `Sorted by ${o.measure}; no comparison backs the order.`;
1233
- const lede = `${passed} of ${attempts} ${plural(attempts, noun.attempt)} passed across ${setupsN}.` + (noBar.length === rows.length ? ` No ${noun.setup} has enough units to draw a rate.` : noBar.length ? ` ${noBar.length} of ${setupsN} have too few units to draw a rate.` : "");
1339
+ const uniqueTop = bars.length === 1 || bars.length > 1 && bars[1].rate < top.rate;
1340
+ const finding = ranked ? `${top.label} ranks first, passing ${pct(top.rate)} of ${plural(2, noun.attempt)}.` : bars.length === 0 ? `No ${noun.setup} has enough units to draw a ${o.measure}.` : rows.length === 1 ? `${top.label} passes ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}.` : uniqueTop ? `${top.label} has the highest ${o.measure}, ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}; no comparison backs the order.` : `Sorted by ${o.measure}; no comparison backs the order.`;
1341
+ const lede = `${passed} of ${attempts} ${plural(attempts, noun.attempt)} passed across ${setupsN}.` + (noBar.length && bars.length ? ` ${noBar.length} of ${setupsN} have too few units to draw a rate; they come last.` : "");
1234
1342
  const intervalNames = [...new Set(whiskers.map((row) => intervalName(row.interval)))];
1235
1343
  const read = bars.length ? [
1236
1344
  `Bars show the share of ${plural(2, noun.attempt)} that passed${whiskers.length ? `; whiskers show the ${intervalNames.join(" or ")} interval` : ""}.`,
1237
1345
  tiered ? `The strip under a bar splits ${plural(2, noun.attempt)} by App Grade tier${o.passTier ? `, and the tick marks ${o.passTier} or better` : ""}.` : ""
1238
1346
  ].filter(Boolean).join(" ") : `Counts show ${plural(2, noun.attempt)} passed; no ${noun.setup} has enough units to draw a rate.`;
1239
- const bases = [...new Set(rows.filter((r) => r.cost.perSolvedUsd !== null).map((r) => r.cost.basis))];
1347
+ const bases = [...new Set(rows.filter((r) => perPass(r.cost) !== null).map((r) => r.cost.basis))];
1240
1348
  const method = [
1241
1349
  `${capitalize(o.measure)} is the share of ${plural(2, noun.attempt)} that passed${o.passTier ? `; a pass is App Grade ${o.passTier} or better` : ""}.`,
1242
1350
  intervalNames.length ? `Intervals: ${intervalNames.join(", ")}, per ${noun.setup}.` : "",
@@ -1246,7 +1354,7 @@ function rankedRates(input, o) {
1246
1354
  const exclusions = [
1247
1355
  ...(o.excluded ?? []).map((e) => `${e.count} ${plural(e.count, noun.attempt)} excluded: ${e.why}.`),
1248
1356
  ...noBar.length ? [`No bar for ${noBar.map((r) => `${r.label} (${estimateNote(r.estimate, r.attempts)})`).join(", ")}.`] : [],
1249
- ...noWhisker.length ? [`No whisker for ${noWhisker.map((r) => `${r.label} (${intervalGap(r.interval)})`).join(", ")}.`] : []
1357
+ ...noWhisker.length ? [`No whisker for ${noWhisker.map((r) => `${r.label} (${rateIntervalGap(r)})`).join(", ")}.`] : []
1250
1358
  ];
1251
1359
  const columns = [
1252
1360
  { label: capitalize(noun.setup) },
@@ -1267,7 +1375,7 @@ function rankedRates(input, o) {
1267
1375
  String(row.solved),
1268
1376
  String(row.attempts),
1269
1377
  drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts),
1270
- row.interval ? `${pct(row.interval.lower)}\u2013${pct(row.interval.upper)} (${intervalGap(row.interval) ?? intervalName(row.interval)})` : "none recorded",
1378
+ rateIntervalGap(row) === null ? `${pct(row.interval.lower)}\u2013${pct(row.interval.upper)} (${intervalName(row.interval)}, n=${row.attempts})` : `not drawn (${rateIntervalGap(row)})`,
1271
1379
  ...tiered ? [row.tiers ? [tierSummary(row.tiers), offLadder ? `${offLadder} off the ladder` : ""].filter(Boolean).join(" \xB7 ") || "none" : "not graded"] : [],
1272
1380
  ...layout.cost ? [costText(row.cost, row.solved), row.cost.basis + (row.cost.receipts !== null ? `, ${row.cost.receipts} receipts` : "")] : [],
1273
1381
  ...layout.median ? [row.medianWallMs === null ? "unknown" : duration(row.medianWallMs)] : []
@@ -1297,7 +1405,7 @@ function barMarks(row, x0, x1, y, o) {
1297
1405
  const at = (v) => x0 + Math.max(0, Math.min(1, v)) * w;
1298
1406
  let out = hbar(x0, y, at(row.rate) - x0, 12, "tgc-bar");
1299
1407
  let end = at(row.rate);
1300
- if (row.interval && intervalGap(row.interval) === null) {
1408
+ if (row.interval && rateIntervalGap(row) === null) {
1301
1409
  const lo = at(row.interval.lower);
1302
1410
  const hi = at(row.interval.upper);
1303
1411
  out += line(lo, y + 6, hi, y + 6, "tgc-whisker");
@@ -1329,11 +1437,11 @@ function hoverText(row) {
1329
1437
  `${kn(row.solved, row.attempts)} passed`,
1330
1438
  drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts)
1331
1439
  ];
1332
- if (row.interval && intervalGap(row.interval) === null) {
1440
+ if (row.interval && rateIntervalGap(row) === null) {
1333
1441
  parts.push(`${intervalName(row.interval)} ${pct(row.interval.lower)}\u2013${pct(row.interval.upper)}`);
1334
1442
  }
1335
1443
  if (row.tiers) parts.push(tierSummary(row.tiers));
1336
- if (row.cost.perSolvedUsd !== null) parts.push(`${costText(row.cost, row.solved)} per pass`);
1444
+ if (perPass(row.cost) !== null) parts.push(`${costText(row.cost, row.solved)} per pass`);
1337
1445
  return parts.join(" \xB7 ");
1338
1446
  }
1339
1447
  function wide2(rows, layout, noun, o) {
@@ -1356,7 +1464,7 @@ function wide2(rows, layout, noun, o) {
1356
1464
  marks += drawable(row.estimate) ? barMarks(row, x0, x1, y + 7, o) : text(x0, y + 18, estimateNote(row.estimate, row.attempts), { cls: "tgc-ink-muted" });
1357
1465
  marks += text(knRight, y + 18, kn(row.solved, row.attempts), { cls: "tgc-ink tgc-num", anchor: "end" });
1358
1466
  if (layout.cost) {
1359
- const billed = row.cost.perSolvedUsd !== null && row.cost.basis === "receipts";
1467
+ const billed = perPass(row.cost) !== null && row.cost.basis === "receipts";
1360
1468
  marks += text(costRight, y + 18, costText(row.cost, row.solved), {
1361
1469
  cls: billed ? "tgc-ink tgc-num" : "tgc-ink-muted tgc-num",
1362
1470
  anchor: "end"
@@ -1413,7 +1521,7 @@ function narrow(rows, layout, o) {
1413
1521
  const facts = [
1414
1522
  `${kn(row.solved, row.attempts)} passed`,
1415
1523
  ...drawable(row.estimate) ? [] : [estimateNote(row.estimate, row.attempts)],
1416
- ...layout.cost && !(row.solved === 0 && row.cost.perSolvedUsd === null) ? [row.cost.perSolvedUsd === null ? "cost unknown" : `${costText(row.cost, row.solved)} per pass`] : [],
1524
+ ...layout.cost && !(row.solved === 0 && perPass(row.cost) === null) ? [perPass(row.cost) === null ? "cost unknown" : `${costText(row.cost, row.solved)} per pass`] : [],
1417
1525
  ...layout.median ? [row.medianWallMs === null ? "run time unknown" : `${duration(row.medianWallMs)} run`] : []
1418
1526
  ];
1419
1527
  const factLines = fitLines(facts, NARROW, 12, "mono");
@@ -1503,7 +1611,7 @@ function describe(task, setup, cell, means) {
1503
1611
  else parts.push(`${cell.attempts} ${plural(cell.attempts, "attempt")}`);
1504
1612
  const tiers = tierSummary(cell.tiers, cell.unmeasured, cell.flagged);
1505
1613
  if (tiers) parts.push(tiers);
1506
- if (cell.costUsd !== null) parts.push(usd(cell.costUsd));
1614
+ if (cell.costUsd !== null) parts.push(cell.costUsd > 0 ? usd(cell.costUsd) : "cost unknown");
1507
1615
  return parts.join(" \xB7 ").replace(`${setup.label} \xB7 `, `${setup.label}: `);
1508
1616
  }
1509
1617
  function signature(cell) {
@@ -1523,11 +1631,26 @@ function taskMatrix(tasks, setupsIn, cells, o = {}) {
1523
1631
  return { id, refused: `A cell names ${cell.task} \xD7 ${cell.setup}, which is not in the ${noun.task} and ${noun.setup} lists.` };
1524
1632
  }
1525
1633
  if (byKey.has(key)) return { id, refused: `Two cells for ${cell.task} \xD7 ${cell.setup}.` };
1634
+ const counts = [cell.attempts, cell.solved, cell.unmeasured, cell.flagged];
1635
+ if (counts.some((n) => n !== null && n !== void 0 && (!Number.isInteger(n) || n < 0))) {
1636
+ return { id, refused: `${cell.task} \xD7 ${cell.setup}: counts must be nonnegative whole numbers.` };
1637
+ }
1638
+ if (cell.tiers && TIERS_BEST_FIRST.some((tier) => !Number.isInteger(cell.tiers[tier]) || cell.tiers[tier] < 0)) {
1639
+ return { id, refused: `${cell.task} \xD7 ${cell.setup}: tier counts must be nonnegative whole numbers.` };
1640
+ }
1641
+ if (cell.mean !== null && !Number.isFinite(cell.mean)) return { id, refused: `${cell.task} \xD7 ${cell.setup}: mean is not finite.` };
1642
+ if (cell.costUsd !== null && (!Number.isFinite(cell.costUsd) || cell.costUsd < 0)) {
1643
+ return { id, refused: `${cell.task} \xD7 ${cell.setup}: cost is invalid.` };
1644
+ }
1526
1645
  if (!means && cell.attempts !== null) {
1527
1646
  const graded = tierTotal(cell.tiers) + (cell.flagged ?? 0) + (cell.unmeasured ?? 0);
1528
1647
  if (graded > cell.attempts || cell.solved !== null && cell.solved > cell.attempts) {
1529
1648
  return { id, refused: `${cell.task} \xD7 ${cell.setup}: grades or passes exceed its ${cell.attempts} attempts.` };
1530
1649
  }
1650
+ if (o.passTier && cell.tiers && cell.solved !== null) {
1651
+ const passing = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + cell.tiers[tier], 0);
1652
+ if (passing !== cell.solved) return { id, refused: `${cell.task} \xD7 ${cell.setup}: ${cell.solved} passes disagree with ${passing} grades at ${o.passTier} or better.` };
1653
+ }
1531
1654
  }
1532
1655
  byKey.set(key, cell);
1533
1656
  }
@@ -1599,7 +1722,7 @@ function taskMatrix(tasks, setupsIn, cells, o = {}) {
1599
1722
  const cell = at(task, s);
1600
1723
  const main = cellText(cell, means);
1601
1724
  const extra = !means && cell && cell.solved !== null && cell.tiers ? ` \xB7 ${tierSummary(cell.tiers, cell.unmeasured, cell.flagged)}` : "";
1602
- const cost = cell && cell.costUsd !== null ? ` \xB7 ${usd(cell.costUsd)}` : "";
1725
+ const cost = cell && cell.costUsd !== null ? ` \xB7 ${cell.costUsd > 0 ? usd(cell.costUsd) : "cost unknown"}` : "";
1603
1726
  return main === "\xB7" ? "not run" : `${main}${extra}${cost}`;
1604
1727
  })
1605
1728
  ])
@@ -1764,6 +1887,7 @@ export {
1764
1887
  figureCss,
1765
1888
  isRefusal,
1766
1889
  rankedRates,
1890
+ rateOrder,
1767
1891
  renderFigure,
1768
1892
  renderNotes,
1769
1893
  renderRefusals,
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@tangle-network/charts",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Benchmark figures as static SVG and HTML tables: ranked rates, cost frontier, comparison intervals, task matrix and breakdown. Draws from producer data and refuses what it cannot draw honestly.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/tangle-network/brand",
9
+ "url": "git+https://github.com/tangle-network/brand.git",
10
10
  "directory": "packages/charts"
11
11
  },
12
12
  "bugs": {
@@ -28,10 +28,6 @@
28
28
  },
29
29
  "./figure.css": "./dist/figure.css"
30
30
  },
31
- "scripts": {
32
- "gen:css": "node scripts/gen-css.mjs",
33
- "build": "node scripts/gen-css.mjs --check && tsup src/index.ts --format esm --dts --clean && cp src/figure.css dist/figure.css"
34
- },
35
31
  "devDependencies": {
36
32
  "tsup": "^8.3.5",
37
33
  "typescript": "^5.6.0"
@@ -41,5 +37,9 @@
41
37
  "charts",
42
38
  "benchmarks",
43
39
  "svg"
44
- ]
45
- }
40
+ ],
41
+ "scripts": {
42
+ "gen:css": "node scripts/gen-css.mjs",
43
+ "build": "node scripts/gen-css.mjs --check && tsup src/index.ts --format esm --dts --clean && cp src/figure.css dist/figure.css"
44
+ }
45
+ }