@bongos/core 1.19.25 → 1.19.26

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.25",
6
- "core_contract": "1.19.25",
7
- "source_commit": "5946896ddb840e6d7a52eadf92a5e42d47d716fa",
5
+ "core_version": "1.19.26",
6
+ "core_contract": "1.19.26",
7
+ "source_commit": "522fdcf67202b1961ce21cc5bb1e9e21f3455105",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T13:18:22.297Z",
9
+ "built_at": "2026-08-11T13:25:13.567Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 285,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1204,
14
+ "functional_verbatim": 1205,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1510,
20
- "tree_sha256": "159afabb8c0c958bde0c962fb152ab372f6c6e34333f2d6718a4b2f2e40dd47b",
19
+ "file_count": 1511,
20
+ "tree_sha256": "ecaa7b2c50ea700a1da02925b30bc638ccef3e362d27f47a50c59957afc232be",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2822,7 +2822,7 @@
2822
2822
  {
2823
2823
  "path": "modules/grading/db.js",
2824
2824
  "mode": "0000644",
2825
- "sha256": "ed7df5654cead0c89945a53c2de65bbdb7549aeeb7b73845143bced5a451d77b"
2825
+ "sha256": "b651b73c65b0da33284430b430638dbc39b34db43846a05ab431deaabcc44b3b"
2826
2826
  },
2827
2827
  {
2828
2828
  "path": "modules/grading/grader-rubric.json",
@@ -2932,7 +2932,7 @@
2932
2932
  {
2933
2933
  "path": "modules/hall-ui/public/builders.js",
2934
2934
  "mode": "0000644",
2935
- "sha256": "dcb19e4ec7a5df5299f84d70dc80fcc09d0ecf3110d89dda9ec40d1444df376d"
2935
+ "sha256": "5bdd907f10ae15f8efdb55adce3ce23991a376122eb79c549e025f8f4845fae5"
2936
2936
  },
2937
2937
  {
2938
2938
  "path": "modules/hall-ui/public/chime.wav",
@@ -3352,7 +3352,7 @@
3352
3352
  {
3353
3353
  "path": "modules/lifecycle/db.js",
3354
3354
  "mode": "0000644",
3355
- "sha256": "7dc50e4e88a204583c753555c76f4103df405a143c4266dbff53793c292803f9"
3355
+ "sha256": "85c856016e548399538734098d2f08d3920fb52814759ffa314ed26d98bf32eb"
3356
3356
  },
3357
3357
  {
3358
3358
  "path": "modules/lifecycle/done-when.js",
@@ -4002,12 +4002,12 @@
4002
4002
  {
4003
4003
  "path": "package-lock.json",
4004
4004
  "mode": "0000644",
4005
- "sha256": "61a5251f8c5ae2be0e5d3cce0dbff87afc4ec3b5327d128a04ad67b6283b23ba"
4005
+ "sha256": "08c0827545637895b730387d92d4a0ce7fef7b055a966cb88ab70e6be901880c"
4006
4006
  },
4007
4007
  {
4008
4008
  "path": "package.json",
4009
4009
  "mode": "0000644",
4010
- "sha256": "11341042a94fdb5a03fd8d318a6471763f0b97d46efb13b00743f4abee686839"
4010
+ "sha256": "1ac1c894e7f11d5502855775e3f2ca37031927a68ec7e8dca447661b872467e5"
4011
4011
  },
4012
4012
  {
4013
4013
  "path": "public-docs/index.html",
@@ -5627,7 +5627,7 @@
5627
5627
  {
5628
5628
  "path": "src/module-api.js",
5629
5629
  "mode": "0000644",
5630
- "sha256": "3b6cabeb91e7181b5d83689bb3e81b4113e0c1a357e6b040fc0caecbf405802b"
5630
+ "sha256": "39b05d86d4fbb1035769ff0c30a97b21c75eea8ee145a25784789d17fb20aa38"
5631
5631
  },
5632
5632
  {
5633
5633
  "path": "src/module-loader/loader.js",
@@ -6564,6 +6564,11 @@
6564
6564
  "mode": "0000644",
6565
6565
  "sha256": "a2d76ca391999e54b0767c3cba8eba34e84d5c704c3ac98370195ef9889bd152"
6566
6566
  },
6567
+ {
6568
+ "path": "tests/grade_aggregates_unavailable.mjs",
6569
+ "mode": "0000644",
6570
+ "sha256": "a55ae09e94648035479ff214ac26c2c84f2fc3c166237f06d296a6fd6608ec48"
6571
+ },
6567
6572
  {
6568
6573
  "path": "tests/grade_attempts.mjs",
6569
6574
  "mode": "0000644",
@@ -18,22 +18,33 @@
18
18
  const api = require('../../src/module-api');
19
19
  const { pool } = api;
20
20
 
21
- // Rolling grade stats for a builder over the last `days`: { n, avg_score, n_passed }.
22
- // Windows by graded_at; `shipped_by` attributes the grade to the builder who did
23
- // the work. Returns a zero row when the builder has no grades in the window.
21
+ // Rolling grade stats for a builder over the last `days`: { n, avg_score,
22
+ // n_passed, n_unavailable }. Windows by graded_at; `shipped_by` attributes the
23
+ // grade to the builder who did the work. Returns a zero row when the builder
24
+ // has no grades in the window.
25
+ //
26
+ // Quality stats count ONLY quality-graded rows (task 1002652): a grader outage
27
+ // (#943 — signals.panel_outcome='unavailable') or a json-null gating axis
28
+ // (functional_fidelity / code_quality) is bucketed as n_unavailable, never a
29
+ // 0-score fail. The predicate is a documented twin of lifecycle/db.js
30
+ // `gradeQualityWhere` (module boundary — keep the two in sync).
24
31
  async function builderRollingAvgGrade(builderId, days = 14) {
32
+ const q = `(COALESCE(g.signals->>'panel_outcome','') <> 'unavailable'
33
+ AND COALESCE(jsonb_typeof(g.rubric_scores->'functional_fidelity'),'') <> 'null'
34
+ AND COALESCE(jsonb_typeof(g.rubric_scores->'code_quality'),'') <> 'null')`;
25
35
  const { rows } = await pool.query(
26
36
  `SELECT
27
- COUNT(*)::int AS n,
28
- COALESCE(AVG(g.score), 0)::numeric(3,1) AS avg_score,
29
- COALESCE(SUM(CASE WHEN g.passed THEN 1 ELSE 0 END), 0)::int AS n_passed
37
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
38
+ COALESCE(AVG(g.score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
39
+ COALESCE(SUM(CASE WHEN g.passed THEN 1 ELSE 0 END) FILTER (WHERE ${q}), 0)::int AS n_passed,
40
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
30
41
  FROM task_grades g
31
42
  JOIN tasks t ON t.id = g.task_id
32
43
  WHERE t.shipped_by = $1
33
44
  AND g.graded_at >= now() - ($2 || ' days')::interval`,
34
45
  [builderId, String(days)]
35
46
  );
36
- return rows[0] || { n: 0, avg_score: 0, n_passed: 0 };
47
+ return rows[0] || { n: 0, avg_score: 0, n_passed: 0, n_unavailable: 0 };
37
48
  }
38
49
 
39
50
  module.exports = { builderRollingAvgGrade };
@@ -1457,15 +1457,20 @@
1457
1457
  return;
1458
1458
  }
1459
1459
  tbody.innerHTML = grades.map((g) => {
1460
- const score = Number(g.score).toFixed(1);
1461
- const verdict = g.passed
1462
- ? `<span style="color:var(--olive,#5b7a4d);font-weight:600">passed</span>`
1463
- : `<span style="color:#a04040;font-weight:600">fell short</span>`;
1460
+ // A grader OUTAGE row (panel_outcome 'unavailable') carries a placeholder
1461
+ // score of 0 — label it, never render it as a 0.0 fail (task 1002652).
1462
+ const isOutage = g.panel_outcome === 'unavailable';
1463
+ const score = isOutage ? '—' : Number(g.score).toFixed(1);
1464
+ const verdict = isOutage
1465
+ ? `<span style="color:var(--ink-soft,#8a8577)">not graded (grader outage)</span>`
1466
+ : g.passed
1467
+ ? `<span style="color:var(--olive,#5b7a4d);font-weight:600">passed</span>`
1468
+ : `<span style="color:#a04040;font-weight:600">fell short</span>`;
1464
1469
  return `
1465
1470
  <tr>
1466
1471
  <td class="ledger__num">#${g.task_id}</td>
1467
1472
  <td>${escapeHtml(g.title || '')}</td>
1468
- <td class="ledger__drachmae">${score} / 10</td>
1473
+ <td class="ledger__drachmae">${isOutage ? '—' : `${score} / 10`}</td>
1469
1474
  <td>${verdict}</td>
1470
1475
  <td class="ledger__date">${fmtDate(g.graded_at)}</td>
1471
1476
  </tr>
@@ -1603,7 +1608,9 @@
1603
1608
  if (!isEco) {
1604
1609
  charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
1605
1610
  `total ${usd(h.total_cost_usd)}`));
1606
- charts.push(chartCard('Grade pass rate', (data.grade_trend || []).map((r) => (r.n ? r.n_passed / r.n : 0)), 'var(--accent)',
1611
+ // n counts quality-graded rows only (task 1002652); a day whose grades
1612
+ // were all outages has n=0 and must not plot as a 0% dip.
1613
+ charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',
1607
1614
  'share of work passing review'));
1608
1615
  }
1609
1616
  gridEl.innerHTML = charts.join('');
@@ -83,15 +83,34 @@ const staleClaimHourPredicate = (param) =>
83
83
  AND c.claimed_at < now() - (${param}::int * interval '1 hour'))`;
84
84
 
85
85
  // grade pass-count fragment: SUM of passed-as-1. `col` is the (possibly
86
- // alias-qualified) boolean column ('passed' or 'g.passed'); pass
87
- // { coalesce: false } for the two sites that omit the COALESCE(...,0) wrap
88
- // (audit §2B db-tx — alias × coalesce are the two preserved drift dimensions).
89
-
90
- const gradePassCount = (col, { coalesce = true } = {}) => {
91
- const inner = `SUM(CASE WHEN ${col} THEN 1 ELSE 0 END)`;
86
+ // alias-qualified) boolean column ('passed' or 'g.passed'); { where } restricts
87
+ // the sum to quality-graded rows via FILTER (task 1002652 — every aggregate
88
+ // site passes gradeQualityWhere, and keeps coalesce on because FILTER can zero
89
+ // out a group). { coalesce: false } preserved for callers that want a raw SUM.
90
+
91
+ const gradePassCount = (col, { coalesce = true, where = null } = {}) => {
92
+ const filter = where ? ` FILTER (WHERE ${where})` : '';
93
+ const inner = `SUM(CASE WHEN ${col} THEN 1 ELSE 0 END)${filter}`;
92
94
  return coalesce ? `COALESCE(${inner}, 0)::int` : `${inner}::int`;
93
95
  };
94
96
 
97
+ // Quality-graded row predicate (task 1002652). Two task_grades shapes carry
98
+ // score 0 / passed=false that are NOT quality verdicts and must never enter an
99
+ // aggregate: a grader OUTAGE (#943 — signals.panel_outcome='unavailable',
100
+ // rubric axes json-null) and a panel row whose gating axis
101
+ // (functional_fidelity / code_quality) is json-null (an errored worker, not a
102
+ // judged 0). jsonb_typeof + COALESCE keep legacy rows counted (missing keys /
103
+ // SQL-NULL rubric_scores → typeof NULL → '' ≠ 'null') and keep the predicate
104
+ // NULL-safe under NOT (a bare NULL operand would drop rows from the
105
+ // n_unavailable bucket).
106
+
107
+ const gradeQualityWhere = (alias = '') => {
108
+ const a = alias ? `${alias}.` : '';
109
+ return `(COALESCE(${a}signals->>'panel_outcome','') <> 'unavailable'
110
+ AND COALESCE(jsonb_typeof(${a}rubric_scores->'functional_fidelity'),'') <> 'null'
111
+ AND COALESCE(jsonb_typeof(${a}rubric_scores->'code_quality'),'') <> 'null')`;
112
+ };
113
+
95
114
  // The shared `bounds`/`days` DAY_SERIES_CTE for the costSummary day-series
96
115
  // queries moved with costSummary into modules/economy/cost.js (BV1.R77 / ADR
97
116
  // 0093 §§1-2) — its only two callers left the monolith.
@@ -4856,9 +4875,13 @@ async function recentShippedTasksByBuilder(builderId, limit = 10) {
4856
4875
  // this is safe to call freely.
4857
4876
 
4858
4877
  async function recentGradesForBuilder(builderId, limit = 5) {
4878
+ // panel_outcome rides along so consumers can LABEL an outage row ("not
4879
+ // graded") instead of rendering its placeholder 0.0 as a fail (task 1002652).
4880
+ // A list read stays unfiltered — honesty here means labeling, not hiding.
4859
4881
  const { rows } = await pool.query(
4860
4882
  `SELECT g.task_id, g.score, g.passed, g.threshold, g.graded_at,
4861
4883
  g.grader_kind, g.grader_version, g.rubric_scores,
4884
+ g.signals->>'panel_outcome' AS panel_outcome,
4862
4885
  COALESCE(g.signals->'issues', '[]'::jsonb) AS issues,
4863
4886
  COALESCE((g.signals->>'confidence')::numeric, NULL) AS confidence,
4864
4887
  t.title, t.kind, t.discipline
@@ -4876,33 +4899,43 @@ async function recentGradesForBuilder(builderId, limit = 5) {
4876
4899
  // public status tile + leaderboard hover.
4877
4900
 
4878
4901
  async function rollingAvgGrade(days = 7) {
4902
+ // Quality stats aggregate ONLY quality-graded rows; outage / null-axis rows
4903
+ // are counted apart as n_unavailable (task 1002652 — 21 outage rows once
4904
+ // read as a month of pass_rate 0.000).
4905
+ const q = gradeQualityWhere();
4879
4906
  const { rows } = await pool.query(
4880
4907
  `SELECT
4881
- COUNT(*)::int AS n,
4882
- COALESCE(AVG(score), 0)::numeric(3,1) AS avg_score,
4883
- ${gradePassCount('passed')} AS n_passed,
4884
- COALESCE(MIN(score), 0)::numeric(3,1) AS min_score,
4885
- COALESCE(MAX(score), 0)::numeric(3,1) AS max_score
4908
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
4909
+ COALESCE(AVG(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
4910
+ ${gradePassCount('passed', { where: q })} AS n_passed,
4911
+ COALESCE(MIN(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS min_score,
4912
+ COALESCE(MAX(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS max_score,
4913
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
4886
4914
  FROM task_grades
4887
4915
  WHERE graded_at >= now() - ($1 || ' days')::interval`,
4888
4916
  [String(days)]
4889
4917
  );
4890
- return rows[0] || { n: 0, avg_score: 0, n_passed: 0, min_score: 0, max_score: 0 };
4918
+ return rows[0] || { n: 0, avg_score: 0, n_passed: 0, min_score: 0, max_score: 0, n_unavailable: 0 };
4891
4919
  }
4892
4920
 
4893
4921
  // Per-builder rolling avg, last `days` days. Powers the /builders profile.
4894
4922
 
4895
4923
  async function gradeByBuilder(days = 30) {
4924
+ // pass_rate is NULL (not 0.000) for a builder whose window holds only
4925
+ // outage / null-axis rows — "no graded work", never a fabricated fail-streak
4926
+ // (task 1002652). fmtPct-style consumers already render null as '—'.
4927
+ const q = gradeQualityWhere('g');
4896
4928
  const { rows } = await pool.query(
4897
4929
  `SELECT
4898
4930
  t.shipped_by AS builder_id,
4899
4931
  b.github_login,
4900
4932
  b.display_name,
4901
4933
  b.avatar_url,
4902
- COUNT(*)::int AS n,
4903
- COALESCE(AVG(g.score), 0)::numeric(3,1) AS avg_score,
4904
- ${gradePassCount('g.passed')} AS n_passed,
4905
- ROUND(COALESCE(AVG(CASE WHEN g.passed THEN 1 ELSE 0 END), 0), 3) AS pass_rate,
4934
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
4935
+ COALESCE(AVG(g.score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
4936
+ ${gradePassCount('g.passed', { where: q })} AS n_passed,
4937
+ ROUND(AVG(CASE WHEN g.passed THEN 1 ELSE 0 END) FILTER (WHERE ${q}), 3) AS pass_rate,
4938
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable,
4906
4939
  MAX(g.graded_at) AS last_graded_at
4907
4940
  FROM task_grades g
4908
4941
  JOIN tasks t ON t.id = g.task_id
@@ -4920,12 +4953,17 @@ async function gradeByBuilder(days = 30) {
4920
4953
  // chart on status.amazonprimea.com.
4921
4954
 
4922
4955
  async function gradeTrendByDay(days = 30) {
4956
+ // A day whose rows are all outages keeps its row (n=0, avg_score NULL,
4957
+ // n_unavailable>0) so a chart can show "grader down", never a 0.0-score day
4958
+ // (task 1002652 — the 2026-07-12..19 zero-score week was five outage days).
4959
+ const q = gradeQualityWhere();
4923
4960
  const { rows } = await pool.query(
4924
4961
  `SELECT
4925
4962
  to_char(date_trunc('day', graded_at), 'YYYY-MM-DD') AS day,
4926
- COUNT(*)::int AS n,
4927
- AVG(score)::numeric(3,1) AS avg_score,
4928
- ${gradePassCount('passed', { coalesce: false })} AS n_passed
4963
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
4964
+ AVG(score) FILTER (WHERE ${q})::numeric(3,1) AS avg_score,
4965
+ ${gradePassCount('passed', { where: q })} AS n_passed,
4966
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
4929
4967
  FROM task_grades
4930
4968
  WHERE graded_at >= now() - ($1 || ' days')::interval
4931
4969
  GROUP BY date_trunc('day', graded_at)
@@ -4989,11 +5027,14 @@ async function builderAnalytics(builderId, days = 90) {
4989
5027
  [builderId, d]
4990
5028
  ),
4991
5029
  // Grade pass-rate per day (quality holding up as throughput rises?).
5030
+ // Quality-graded rows only — outage / null-axis rows would read as
5031
+ // fail-days (task 1002652); headline.grade_pass_rate derives from these
5032
+ // rows so it inherits the same honesty.
4992
5033
  pool.query(
4993
5034
  `SELECT to_char(date_trunc('day', g.graded_at), 'YYYY-MM-DD') AS day,
4994
- COUNT(*)::int AS n,
4995
- ${gradePassCount('g.passed', { coalesce: false })} AS n_passed,
4996
- AVG(g.score)::numeric(3,1) AS avg_score
5035
+ COUNT(*) FILTER (WHERE ${gradeQualityWhere('g')})::int AS n,
5036
+ ${gradePassCount('g.passed', { where: gradeQualityWhere('g') })} AS n_passed,
5037
+ AVG(g.score) FILTER (WHERE ${gradeQualityWhere('g')})::numeric(3,1) AS avg_score
4997
5038
  FROM task_grades g
4998
5039
  JOIN tasks t ON t.id = g.task_id
4999
5040
  WHERE t.shipped_by = $1
@@ -5457,6 +5498,7 @@ module.exports = {
5457
5498
  rollingAvgGrade,
5458
5499
  gradeByBuilder,
5459
5500
  gradeTrendByDay,
5501
+ gradeQualityWhere, // exported for tests/grade_aggregates_unavailable.mjs (task 1002652)
5460
5502
  builderAnalytics,
5461
5503
  ecosystemAnalytics,
5462
5504
  createOverrideRequest,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.25",
3
+ "version": "1.19.26",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.25",
9
+ "version": "1.19.26",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.25",
3
+ "version": "1.19.26",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -477,8 +477,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
477
477
  // landed since 1.19.23 with no explicit bump. run 31450459825. (task 1002620)
478
478
  // 1.19.25 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
479
479
  // landed since 1.19.24 with no explicit bump. run 31495418623. (task 1002620)
480
+ // 1.19.26 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
481
+ // landed since 1.19.25 with no explicit bump. run 31495996665. (task 1002620)
480
482
  // ---------------------------------------------------------------------------
481
- const CORE_VERSION = '1.19.25'; // CI auto-patch carrier (ADR 0161); see changelog above
483
+ const CORE_VERSION = '1.19.26'; // CI auto-patch carrier (ADR 0161); see changelog above
482
484
 
483
485
  // A namespaced logger so a module's log lines are attributable + consistent.
484
486
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,114 @@
1
+ // tests/grade_aggregates_unavailable.mjs
2
+ //
3
+ // Unit tests for task 1002652 — a grader OUTAGE row (#943: score 0, passed
4
+ // false, signals.panel_outcome='unavailable', rubric axes json-null) and a
5
+ // null-gating-axis panel row must never enter a quality aggregate. 21 outage
6
+ // rows once read as a month of pass_rate 0.000 on /public/grades and the hall.
7
+ //
8
+ // The SQL itself runs against live Postgres in production; here we pin the
9
+ // QUERY CONTRACT the same way the other pool.query readers are pinned (see
10
+ // versions_route.mjs note): pool.query is stubbed to capture SQL, and each
11
+ // aggregate's captured text must carry the quality predicate + the
12
+ // n_unavailable bucket. gradeQualityWhere's NULL-safety (COALESCE around every
13
+ // operand) is what keeps legacy rows counted AND the NOT-bucket total — a bare
14
+ // NULL operand under NOT would silently drop rows from both buckets.
15
+ //
16
+ // Run: node tests/grade_aggregates_unavailable.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const { pool } = require('../src/bongos/pool.js');
23
+ const db = require('../modules/lifecycle/db.js');
24
+ const gradingDb = require('../modules/grading/db.js');
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ async function test(name, fn) {
29
+ try { await fn(); passed++; console.log(` ok ${name}`); }
30
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
31
+ }
32
+
33
+ // Capture every SQL text pool.query sees while `fn` runs; always restore.
34
+ async function captureSql(fn) {
35
+ const captured = [];
36
+ const orig = pool.query;
37
+ pool.query = async (sql) => { captured.push(String(sql)); return { rows: [] }; };
38
+ try { await fn(); } finally { pool.query = orig; }
39
+ return captured;
40
+ }
41
+
42
+ const PREDICATE_MARKS = [
43
+ "panel_outcome", "'unavailable'",
44
+ "jsonb_typeof", "functional_fidelity", "code_quality",
45
+ ];
46
+
47
+ function assertQualityFiltered(sql, label) {
48
+ for (const mark of PREDICATE_MARKS) {
49
+ assert.ok(sql.includes(mark), `${label}: SQL must carry quality-predicate mark "${mark}"`);
50
+ }
51
+ assert.ok(sql.includes('FILTER (WHERE'), `${label}: aggregates must FILTER on the predicate`);
52
+ assert.ok(sql.includes('n_unavailable'), `${label}: must bucket excluded rows as n_unavailable`);
53
+ // NULL-safety: every jsonb_typeof operand must be COALESCE-wrapped, or NOT
54
+ // (predicate) goes NULL on legacy rows and drops them from n_unavailable.
55
+ const typeofCount = (sql.match(/jsonb_typeof/g) || []).length;
56
+ const guardedCount = (sql.match(/COALESCE\(jsonb_typeof/g) || []).length;
57
+ assert.equal(guardedCount, typeofCount, `${label}: every jsonb_typeof must be COALESCE-guarded`);
58
+ }
59
+
60
+ await test('gradeQualityWhere: parenthesized, alias-qualified, NULL-safe on all three operands', () => {
61
+ const bare = db.gradeQualityWhere();
62
+ const aliased = db.gradeQualityWhere('g');
63
+ assert.ok(bare.startsWith('(') && bare.trimEnd().endsWith(')'), 'must parenthesize (composes under NOT/AND)');
64
+ assert.ok(aliased.includes('g.signals') && aliased.includes('g.rubric_scores'), 'alias must qualify both columns');
65
+ assert.ok(!bare.includes('g.'), 'bare form must not smuggle an alias');
66
+ assert.ok(bare.includes("COALESCE(") && bare.includes("panel_outcome"), 'panel_outcome operand must be COALESCE-guarded');
67
+ assert.equal((bare.match(/COALESCE\(jsonb_typeof/g) || []).length, 2, 'both rubric axes COALESCE-guarded');
68
+ });
69
+
70
+ await test('rollingAvgGrade excludes outage/null-axis rows and buckets n_unavailable', async () => {
71
+ const sql = (await captureSql(() => db.rollingAvgGrade(7)))[0];
72
+ assertQualityFiltered(sql, 'rollingAvgGrade');
73
+ });
74
+
75
+ await test('gradeByBuilder: quality-filtered, and pass_rate is NULL (not 0.000) with no quality rows', async () => {
76
+ const sql = (await captureSql(() => db.gradeByBuilder(30)))[0];
77
+ assertQualityFiltered(sql, 'gradeByBuilder');
78
+ // The old shape ROUND(COALESCE(AVG(...),0),3) fabricated a 0.000 fail-streak
79
+ // for an all-outage window — the lars589 symptom. COALESCE must be gone from
80
+ // the pass_rate expression so it reads NULL ("no graded work") instead.
81
+ assert.ok(!sql.includes('ROUND(COALESCE(AVG'), 'pass_rate must not coalesce an empty window to 0');
82
+ assert.ok(/AVG\(CASE WHEN g\.passed THEN 1 ELSE 0 END\) FILTER/.test(sql), 'pass_rate must average quality rows only');
83
+ });
84
+
85
+ await test('gradeTrendByDay keeps outage-only days as n=0 + n_unavailable, never a 0.0-score day', async () => {
86
+ const sql = (await captureSql(() => db.gradeTrendByDay(30)))[0];
87
+ assertQualityFiltered(sql, 'gradeTrendByDay');
88
+ assert.ok(!/WHERE[^G]*unavailable[^G]*GROUP BY/s.test(sql.replace(/FILTER \(WHERE[^)]+\)+/g, '')),
89
+ 'exclusion must be FILTER-based (top-level WHERE would drop outage-only days from the series)');
90
+ });
91
+
92
+ await test('recentGradesForBuilder labels rather than hides: selects panel_outcome, no FILTER', async () => {
93
+ const sql = (await captureSql(() => db.recentGradesForBuilder(1, 5)))[0];
94
+ assert.ok(sql.includes("signals->>'panel_outcome'"), 'must select panel_outcome for display labeling');
95
+ assert.ok(!sql.includes('FILTER (WHERE'), 'the list read stays unfiltered — labeling, not hiding');
96
+ });
97
+
98
+ await test('builderAnalytics grade-trend query is quality-filtered (feeds headline.grade_pass_rate)', async () => {
99
+ const sqls = await captureSql(() => db.builderAnalytics(1, 90));
100
+ const gradeSql = sqls.find((s) => s.includes('task_grades') && s.includes('graded_at'));
101
+ assert.ok(gradeSql, 'builderAnalytics must query task_grades');
102
+ for (const mark of PREDICATE_MARKS) {
103
+ assert.ok(gradeSql.includes(mark), `builderAnalytics grade query must carry "${mark}"`);
104
+ }
105
+ assert.ok(gradeSql.includes('FILTER (WHERE'), 'builderAnalytics grade query must FILTER');
106
+ });
107
+
108
+ await test('grading/db builderRollingAvgGrade (the documented twin) carries the same predicate', async () => {
109
+ const sql = (await captureSql(() => gradingDb.builderRollingAvgGrade(1, 14)))[0];
110
+ assertQualityFiltered(sql, 'builderRollingAvgGrade');
111
+ });
112
+
113
+ console.log(`\n${passed} passed, ${failed} failed`);
114
+ process.exit(failed === 0 ? 0 : 1);