ai-consultants 2.19.2 → 2.20.0

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.
@@ -35,6 +35,10 @@ _AFFINITY_DATA=""
35
35
  # portable workaround. At ~135 entries x ~25 chars = ~3KB, memory is a
36
36
  # non-issue.
37
37
  _AFFINITY_RESULT_CACHE=" "
38
+ # Parallel per-key cache for capability lookups: " CONSULTANT|AXIS=score".
39
+ # Same leading-space bracketing trick as _AFFINITY_RESULT_CACHE to avoid
40
+ # substring collisions. Reset alongside it when the affinity file changes.
41
+ _CAPABILITY_RESULT_CACHE=" "
38
42
 
39
43
  # Resolve the affinity file path using the same search precedence as user
40
44
  # config (v2.12+): explicit AFFINITY_FILE > user config dir > bundled default.
@@ -66,6 +70,7 @@ _load_affinity_data() {
66
70
  fi
67
71
  _AFFINITY_LOADED_FILE="$file"
68
72
  _AFFINITY_RESULT_CACHE=" "
73
+ _CAPABILITY_RESULT_CACHE=" "
69
74
  fi
70
75
  }
71
76
 
@@ -116,6 +121,60 @@ get_affinity() {
116
121
  echo "$score"
117
122
  }
118
123
 
124
+ # Get a consultant's capability score on a quality/efficiency axis.
125
+ # Usage: get_capability <consultant> <axis> (axis: intelligence|taste|cost)
126
+ # Scores are 1-10 (higher = better). A missing consultant/axis, missing
127
+ # 'capabilities' block, or missing jq falls back to capability_default (5).
128
+ # Cached per (consultant, axis), mirroring get_affinity.
129
+ get_capability() {
130
+ local consultant="$1"
131
+ local axis="$2"
132
+
133
+ _load_affinity_data
134
+
135
+ local key=" ${consultant}|${axis}="
136
+ case "$_CAPABILITY_RESULT_CACHE" in
137
+ *"${key}"*)
138
+ local cached="${_CAPABILITY_RESULT_CACHE#*"${key}"}"
139
+ echo "${cached%% *}"
140
+ return 0
141
+ ;;
142
+ esac
143
+
144
+ if [[ -z "$_AFFINITY_DATA" ]] || ! command -v jq >/dev/null 2>&1; then
145
+ echo "${CAPABILITY_DEFAULT:-5}"
146
+ return 0
147
+ fi
148
+
149
+ local score
150
+ score=$(jq -r --arg c "$consultant" --arg ax "$axis" '
151
+ (.capabilities[$c][$ax]) // (.capability_default // 5)
152
+ ' <<<"$_AFFINITY_DATA" 2>/dev/null)
153
+
154
+ [[ -z "$score" || "$score" == "null" ]] && score="${CAPABILITY_DEFAULT:-5}"
155
+ _CAPABILITY_RESULT_CACHE+="${key#" "}${score} "
156
+ echo "$score"
157
+ }
158
+
159
+ # Get the quality axis a question category stresses (intelligence|taste).
160
+ # Usage: get_category_axis <category>
161
+ # Unmapped categories (and missing JSON/jq) default to "intelligence".
162
+ get_category_axis() {
163
+ local category="$1"
164
+
165
+ _load_affinity_data
166
+
167
+ if [[ -z "$_AFFINITY_DATA" ]] || ! command -v jq >/dev/null 2>&1; then
168
+ echo "intelligence"
169
+ return 0
170
+ fi
171
+
172
+ local axis
173
+ axis=$(jq -r --arg cat "$category" '(.category_axis[$cat]) // "intelligence"' <<<"$_AFFINITY_DATA" 2>/dev/null)
174
+ [[ -z "$axis" || "$axis" == "null" ]] && axis="intelligence"
175
+ echo "$axis"
176
+ }
177
+
119
178
  # Select the best consultants for a category
120
179
  # Usage: select_consultants <category> [min_affinity] [max_consultants]
121
180
  # Returns: list of consultants sorted by affinity
@@ -129,12 +188,26 @@ select_consultants() {
129
188
  local selected=()
130
189
  local scores=()
131
190
 
132
- # Collect affinities
191
+ # Collect affinities. When capability-aware routing is on, the ELIGIBILITY
192
+ # filter still uses raw category affinity (same panel as before), but the
193
+ # SORT rank is nudged by the consultant's capability on the axis this
194
+ # category stresses — so under a max/limit the quality-axis reorders which
195
+ # eligible consultants make the cut. Default (flag off): rank == affinity.
196
+ local _cap_axis=""
197
+ if [[ "${ENABLE_CAPABILITY_ROUTING:-false}" == "true" ]]; then
198
+ _cap_axis=$(get_category_axis "$category")
199
+ fi
133
200
  for c in "${consultants[@]}"; do
134
201
  local score=$(get_affinity "$category" "$c")
135
202
  if [[ $score -ge $min_affinity ]]; then
203
+ local rank=$score
204
+ if [[ -n "$_cap_axis" ]]; then
205
+ local cap
206
+ cap=$(get_capability "$c" "$_cap_axis")
207
+ rank=$(( score + cap - ${CAPABILITY_DEFAULT:-5} ))
208
+ fi
136
209
  selected+=("$c")
137
- scores+=("$score")
210
+ scores+=("$rank")
138
211
  fi
139
212
  done
140
213
 
@@ -8,6 +8,44 @@
8
8
  # CONFIDENCE-WEIGHTED VOTING
9
9
  # =============================================================================
10
10
 
11
+ # Resolve the capability-weighting axis for the current run, or "" when
12
+ # capability weighting is off or the routing helpers are unavailable (e.g.
13
+ # voting.sh sourced standalone). Reads the run category from QUESTION_CATEGORY.
14
+ # Centralized so each voting function computes the axis once, not per response.
15
+ _voting_capability_axis() {
16
+ if [[ "${ENABLE_CAPABILITY_WEIGHTING:-false}" != "true" ]]; then
17
+ echo ""
18
+ return 0
19
+ fi
20
+ if ! declare -f get_capability >/dev/null 2>&1 || ! declare -f get_category_axis >/dev/null 2>&1; then
21
+ echo ""
22
+ return 0
23
+ fi
24
+ get_category_axis "${QUESTION_CATEGORY:-GENERAL}"
25
+ }
26
+
27
+ # Effective vote weight for a consultant: its self-reported confidence,
28
+ # optionally modulated by how capable it is on the axis this question's
29
+ # category stresses (capability-aware voting). Degrades to plain confidence
30
+ # when no axis is supplied (feature off / helpers unavailable).
31
+ # Usage: _effective_vote_weight <consultant> <confidence> <axis-or-empty>
32
+ # weight = confidence * (STRENGTH + capability) / STRENGTH (integer math)
33
+ # STRENGTH = CAPABILITY_WEIGHT_STRENGTH (default 10): higher = gentler nudge.
34
+ _effective_vote_weight() {
35
+ local consultant="$1" confidence="$2" axis="$3"
36
+ if [[ -z "$axis" ]]; then
37
+ echo "$confidence"
38
+ return 0
39
+ fi
40
+ local cap strength
41
+ cap=$(get_capability "$consultant" "$axis")
42
+ [[ "$cap" =~ ^[0-9]+$ ]] || cap="${CAPABILITY_DEFAULT:-5}"
43
+ strength="${CAPABILITY_WEIGHT_STRENGTH:-10}"
44
+ [[ "$strength" =~ ^[0-9]+$ ]] || strength=10
45
+ [[ "$strength" -le 0 ]] && strength=10
46
+ echo $(( confidence * (strength + cap) / strength ))
47
+ }
48
+
11
49
  # Calculate the weighted average of confidence scores
12
50
  # Usage: calculate_weighted_average <json_responses_dir>
13
51
  calculate_weighted_average() {
@@ -178,17 +216,22 @@ calculate_weighted_recommendation() {
178
216
  map_clear "APPROACH_WEIGHTS"
179
217
  map_clear "APPROACH_SUPPORTERS"
180
218
 
219
+ # Resolve the capability-weighting axis once (empty when weighting is off)
220
+ local _cap_axis
221
+ _cap_axis=$(_voting_capability_axis)
222
+
181
223
  # Collect weights for each approach
182
224
  for f in "$responses_dir"/*.json; do
183
225
  if [[ -f "$f" && -s "$f" ]]; then
184
- local consultant approach confidence current_weight current_supporters
226
+ local consultant approach confidence current_weight current_supporters weight
185
227
  consultant=$(jq -r '.consultant // "unknown"' "$f" 2>/dev/null)
186
228
  approach=$(jq -r '.response.approach // "unknown"' "$f" 2>/dev/null)
187
229
  confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
188
230
 
189
- # Add weight
231
+ # Add weight (capability-modulated when ENABLE_CAPABILITY_WEIGHTING)
232
+ weight=$(_effective_vote_weight "$consultant" "$confidence" "$_cap_axis")
190
233
  current_weight=$(map_get "APPROACH_WEIGHTS" "$approach")
191
- map_set "APPROACH_WEIGHTS" "$approach" "$((${current_weight:-0} + confidence))"
234
+ map_set "APPROACH_WEIGHTS" "$approach" "$((${current_weight:-0} + weight))"
192
235
 
193
236
  # Add supporter
194
237
  current_supporters=$(map_get "APPROACH_SUPPORTERS" "$approach")
@@ -257,19 +300,27 @@ calculate_final_score() {
257
300
 
258
301
  local weighted_sum=0
259
302
  local total_confidence=0
303
+ local _cap_axis
304
+ _cap_axis=$(_voting_capability_axis)
260
305
 
261
306
  for f in "$responses_dir"/*.json; do
262
307
  if [[ -f "$f" && -s "$f" ]]; then
308
+ local consultant=$(jq -r '.consultant // "unknown"' "$f" 2>/dev/null)
263
309
  local approach=$(jq -r '.response.approach // "unknown"' "$f" 2>/dev/null)
264
310
  local confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
311
+ local weight
312
+ weight=$(_effective_vote_weight "$consultant" "$confidence" "$_cap_axis")
265
313
 
266
- total_confidence=$((total_confidence + confidence))
314
+ # Normalize by the same weight used in the numerator so the score
315
+ # stays on the 1-10 scale. weight == confidence unless capability
316
+ # weighting is enabled.
317
+ total_confidence=$((total_confidence + weight))
267
318
 
268
- # Full weight if supports, half if neutral, zero if dissents
319
+ # Full weight if supports, reduced if dissents (still considered)
269
320
  if [[ "$approach" == "$winning_approach" ]]; then
270
- weighted_sum=$((weighted_sum + confidence * 10)) # Full support
321
+ weighted_sum=$((weighted_sum + weight * 10)) # Full support
271
322
  else
272
- weighted_sum=$((weighted_sum + confidence * 2)) # Dissent but consider
323
+ weighted_sum=$((weighted_sum + weight * 2)) # Dissent but consider
273
324
  fi
274
325
  fi
275
326
  done
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # roster_audit.sh — "uncorrelated value" audit for the consultant roster.
3
+ #
4
+ # A consultant earns its seat only if it contributes signal the others DON'T —
5
+ # a distinct approach, not an echo of the panel. This is the diversity-not-
6
+ # redundancy bar from the model-routing "before a new model earns a row" test
7
+ # (catch >=1 thing both incumbents miss), applied to a consultation panel
8
+ # instead of a review lane.
9
+ #
10
+ # For each past consultation, a consultant's approach is DISTINCT if its keyword
11
+ # set has low Jaccard overlap (< threshold) with EVERY other consultant that
12
+ # round. Aggregated across consultations, a consultant that is rarely distinct
13
+ # is correlated with the rest — a candidate to drop or down-weight; one that is
14
+ # often distinct is pulling its weight on diversity.
15
+ #
16
+ # Reuses the voting.sh keyword/Jaccard machinery. Read-only; no live API calls.
17
+ #
18
+ # Usage: roster_audit.sh [--recent N] [--threshold PCT] [--json] [dir ...]
19
+ # dir... consultation output dirs (each holds <consultant>.json files)
20
+ # --recent N if no dirs given, audit the N most recent under the base (20)
21
+ # --threshold P Jaccard %% at/above which two approaches count as correlated (20)
22
+ # --json emit JSON instead of a table
23
+ # -h, --help show this header
24
+ set -uo pipefail
25
+
26
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ # shellcheck source=lib/voting.sh
28
+ source "$SCRIPT_DIR/lib/voting.sh"
29
+
30
+ MIN_SAMPLE=3 # participations below which a verdict is "insufficient-data"
31
+ acc="" # temp accumulator file (cleaned on EXIT)
32
+ trap '[[ -n "$acc" ]] && rm -f "$acc"' EXIT
33
+
34
+ # Verdict from participation count + distinctiveness percentage.
35
+ # Usage: _verdict <participated> <pct>
36
+ _verdict() {
37
+ local p="$1" pct="$2"
38
+ if [[ "$p" -lt "$MIN_SAMPLE" ]]; then echo "insufficient-data"; return; fi
39
+ if [[ "$pct" -ge 50 ]]; then echo "unique-value"
40
+ elif [[ "$pct" -ge 20 ]]; then echo "some-value"
41
+ else echo "redundant?"; fi
42
+ }
43
+
44
+ _render_table() { # <audited> <threshold> <rows>
45
+ local audited="$1" threshold="$2" rows="$3" redundant="" c p d pct v
46
+ printf 'Roster uncorrelated-value audit (%d consultations, correlated >= %d%%)\n\n' "$audited" "$threshold"
47
+ printf '%-12s %12s %8s %9s %s\n' CONSULTANT PARTICIPATED DISTINCT "DISTINCT%" VERDICT
48
+ while read -r c p d pct; do
49
+ [[ -z "$c" ]] && continue
50
+ v=$(_verdict "$p" "$pct")
51
+ printf '%-12s %12d %8d %8d%% %s\n' "$c" "$p" "$d" "$pct" "$v"
52
+ [[ "$v" == "redundant?" ]] && redundant="${redundant:+$redundant, }$c"
53
+ done <<< "$rows"
54
+ echo
55
+ if [[ -n "$redundant" ]]; then
56
+ printf 'Possibly redundant (>=%d samples, <20%% distinct): %s\n' "$MIN_SAMPLE" "$redundant"
57
+ echo ' -> consider dropping or down-weighting these in references/affinity.json.'
58
+ else
59
+ echo 'No consultant flagged redundant on this sample.'
60
+ fi
61
+ }
62
+
63
+ _render_json() { # <audited> <threshold> <rows>
64
+ local audited="$1" threshold="$2" rows="$3" items="[]" c p d pct v
65
+ while read -r c p d pct; do
66
+ [[ -z "$c" ]] && continue
67
+ v=$(_verdict "$p" "$pct")
68
+ items=$(jq -c --arg c "$c" --argjson p "$p" --argjson d "$d" --argjson pct "$pct" --arg v "$v" \
69
+ '. + [{consultant:$c, participated:$p, distinct:$d, distinct_pct:$pct, verdict:$v}]' <<<"$items")
70
+ done <<< "$rows"
71
+ jq -n --argjson audited "$audited" --argjson threshold "$threshold" --argjson roster "$items" \
72
+ '{consultations_audited:$audited, correlated_threshold_pct:$threshold, roster:$roster}'
73
+ }
74
+
75
+ main() {
76
+ local recent=20 threshold=20 as_json=0
77
+ local dirs=()
78
+ while [[ $# -gt 0 ]]; do
79
+ case "$1" in
80
+ --recent) recent="${2:-20}"; shift 2 ;;
81
+ --threshold) threshold="${2:-20}"; shift 2 ;;
82
+ --json) as_json=1; shift ;;
83
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
84
+ -*) echo "unknown option: $1" >&2; exit 2 ;;
85
+ *) dirs+=("$1"); shift ;;
86
+ esac
87
+ done
88
+
89
+ command -v jq >/dev/null 2>&1 || { echo "roster_audit: jq is required" >&2; exit 2; }
90
+
91
+ # No dirs given -> the N most recent consultation dirs under the base.
92
+ if [[ ${#dirs[@]} -eq 0 ]]; then
93
+ # shellcheck source=config.sh
94
+ source "$SCRIPT_DIR/config.sh" >/dev/null 2>&1 || true
95
+ local base="${DEFAULT_OUTPUT_DIR_BASE:-/tmp/ai_consultations}"
96
+ if [[ ! -d "$base" ]]; then
97
+ echo "roster_audit: no dirs given and base '$base' not found" >&2
98
+ exit 1
99
+ fi
100
+ while IFS= read -r d; do dirs+=("$d"); done < <(
101
+ find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -r | head -n "$recent"
102
+ )
103
+ fi
104
+
105
+ acc="$(mktemp "${TMPDIR:-/tmp}/roster_audit.XXXXXX")"
106
+
107
+ local audited=0 dir f
108
+ for dir in ${dirs[@]+"${dirs[@]}"}; do
109
+ [[ -d "$dir" ]] || continue
110
+ local names=() kw=()
111
+ for f in "$dir"/*.json; do
112
+ [[ -f "$f" && -s "$f" ]] || continue
113
+ local c a al
114
+ c=$(jq -r '.consultant // empty' "$f" 2>/dev/null)
115
+ [[ -z "$c" ]] && continue # not a consultant response file
116
+ a=$(jq -r '.response.approach? // "unknown"' "$f" 2>/dev/null)
117
+ al=$(printf '%s' "$a" | tr '[:upper:]' '[:lower:]')
118
+ case "$al" in unknown|""|n/a|none|"not available") continue ;; esac
119
+ names+=("$c"); kw+=("$(_extract_keywords "$a")")
120
+ done
121
+ local n=${#names[@]}
122
+ [[ $n -lt 2 ]] && continue # need >=2 to assess correlation
123
+ audited=$((audited+1))
124
+ local i j
125
+ for ((i=0; i<n; i++)); do
126
+ local maxsim=0 s
127
+ for ((j=0; j<n; j++)); do
128
+ [[ $i -eq $j ]] && continue
129
+ s=$(_jaccard_similarity "${kw[$i]}" "${kw[$j]}")
130
+ [[ $s -gt $maxsim ]] && maxsim=$s
131
+ done
132
+ local distinct=0
133
+ [[ $maxsim -lt $threshold ]] && distinct=1
134
+ printf '%s %d\n' "${names[$i]}" "$distinct" >> "$acc"
135
+ done
136
+ done
137
+
138
+ if [[ $audited -eq 0 ]]; then
139
+ echo "roster_audit: no consultations with >=2 responders found (nothing to audit)" >&2
140
+ exit 1
141
+ fi
142
+
143
+ # Aggregate: "consultant participated distinct pct", sorted by pct then distinct desc.
144
+ local rows
145
+ rows=$(awk '
146
+ { part[$1]++; dist[$1]+=$2 }
147
+ END { for (c in part){ p=part[c]; d=dist[c]; pct=(p>0)?int(d*100/p):0; printf "%s %d %d %d\n", c, p, d, pct } }
148
+ ' "$acc" | sort -k4,4nr -k3,3nr)
149
+
150
+ if [[ "$as_json" -eq 1 ]]; then
151
+ _render_json "$audited" "$threshold" "$rows"
152
+ else
153
+ _render_table "$audited" "$threshold" "$rows"
154
+ fi
155
+ }
156
+
157
+ main "$@"
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env bash
2
+ # roster_calibrate.sh — MEASURE capability scores from consultation history,
3
+ # replacing the heuristic seeds in references/affinity.json (Tier A).
4
+ #
5
+ # The three axes are derived, not guessed — each from a signal the tool already
6
+ # records, and sliced by the SAME category_axis that consumes them:
7
+ #
8
+ # intelligence = mean blind PEER-REVIEW score on intelligence-axis categories
9
+ # taste = mean blind PEER-REVIEW score on taste-axis categories
10
+ # cost = mean observed $/response (tokens_used x catalog rate),
11
+ # rank-normalized (cheapest = 10). This is cost-per-*task*
12
+ # (verbosity + completion), not per-token sticker price.
13
+ #
14
+ # Requires consultations run with ENABLE_PEER_REVIEW=true (peer scores) and the
15
+ # default cost tracking (tokens_used in metadata). Read-only unless --write.
16
+ #
17
+ # Usage: roster_calibrate.sh [--recent N] [--json] [--write] [dir ...]
18
+ # dir... consultation output dirs (each: <consultant>.json + peer_review/
19
+ # + optimization_metrics.json). Default: N most recent under base.
20
+ # --recent N number of recent consultations to use (default 30)
21
+ # --json emit {measured_capabilities, samples} JSON
22
+ # --write merge measured values into references/affinity.json (backup first)
23
+ # -h, --help show this header
24
+ set -uo pipefail
25
+
26
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ # shellcheck source=lib/routing.sh
28
+ source "$SCRIPT_DIR/lib/routing.sh" # get_category_axis
29
+ COST_RATES="$SCRIPT_DIR/../docs/cost_rates.json"
30
+ AFFINITY_JSON="$SCRIPT_DIR/../references/affinity.json"
31
+
32
+ # Input/output token split convention (mirrors lib/costs.sh's 60/40 estimate)
33
+ # and the fallback rate for a model absent from the catalog (per-1K).
34
+ SPLIT_IN=0.6; SPLIT_OUT=0.4
35
+ FALLBACK_IN=0.005; FALLBACK_OUT=0.015
36
+
37
+ qacc=""; cacc=""
38
+ trap '[[ -n "$qacc" ]] && rm -f "$qacc"; [[ -n "$cacc" ]] && rm -f "$cacc"' EXIT
39
+
40
+ # Per-response cost in dollars from a model + total token count. Rate looked up
41
+ # case-insensitively (callers/consultants vary case); falls back if absent.
42
+ _response_cost() {
43
+ local model="$1" tokens="$2" rin rout
44
+ read -r rin rout < <(jq -r --arg m "$model" '
45
+ (.models | to_entries
46
+ | map(select(.key | ascii_downcase == ($m | ascii_downcase)))
47
+ | .[0].value) as $r
48
+ | if $r then "\($r.input) \($r.output)" else "NA NA" end
49
+ ' "$COST_RATES" 2>/dev/null)
50
+ if [[ "$rin" == "NA" || -z "${rin:-}" ]]; then rin=$FALLBACK_IN; rout=$FALLBACK_OUT; fi
51
+ awk -v t="$tokens" -v i="$rin" -v o="$rout" -v si="$SPLIT_IN" -v so="$SPLIT_OUT" \
52
+ 'BEGIN{ printf "%.8f", (t/1000)*(si*i + so*o) }'
53
+ }
54
+
55
+ # Locate a peer-review aggregate file in <dir>/peer_review by SHAPE (an array of
56
+ # {consultant, average_peer_score}) — robust to its exact filename.
57
+ _find_peer_file() {
58
+ local pdir="$1/peer_review" pf
59
+ [[ -d "$pdir" ]] || return 1
60
+ for pf in "$pdir"/*.json; do
61
+ [[ -f "$pf" ]] || continue
62
+ if jq -e 'type=="array" and length>0 and (.[0]|has("average_peer_score") and has("consultant"))' \
63
+ "$pf" >/dev/null 2>&1; then
64
+ echo "$pf"; return 0
65
+ fi
66
+ done
67
+ return 1
68
+ }
69
+
70
+ main() {
71
+ local recent=30 as_json=0 do_write=0
72
+ local dirs=()
73
+ while [[ $# -gt 0 ]]; do
74
+ case "$1" in
75
+ --recent) recent="${2:-30}"; shift 2 ;;
76
+ --json) as_json=1; shift ;;
77
+ --write) do_write=1; shift ;;
78
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
79
+ -*) echo "unknown option: $1" >&2; exit 2 ;;
80
+ *) dirs+=("$1"); shift ;;
81
+ esac
82
+ done
83
+
84
+ command -v jq >/dev/null 2>&1 || { echo "roster_calibrate: jq is required" >&2; exit 2; }
85
+ command -v awk >/dev/null 2>&1 || { echo "roster_calibrate: awk is required" >&2; exit 2; }
86
+
87
+ if [[ ${#dirs[@]} -eq 0 ]]; then
88
+ # shellcheck source=config.sh
89
+ source "$SCRIPT_DIR/config.sh" >/dev/null 2>&1 || true
90
+ local base="${DEFAULT_OUTPUT_DIR_BASE:-/tmp/ai_consultations}"
91
+ [[ -d "$base" ]] || { echo "roster_calibrate: no dirs given and base '$base' not found" >&2; exit 1; }
92
+ while IFS= read -r d; do dirs+=("$d"); done < <(
93
+ find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -r | head -n "$recent"
94
+ )
95
+ fi
96
+
97
+ qacc="$(mktemp "${TMPDIR:-/tmp}/rc_quality.XXXXXX")" # lines: "consultant axis score"
98
+ cacc="$(mktemp "${TMPDIR:-/tmp}/rc_cost.XXXXXX")" # lines: "consultant cost"
99
+
100
+ local used=0 dir
101
+ for dir in ${dirs[@]+"${dirs[@]}"}; do
102
+ [[ -d "$dir" ]] || continue
103
+
104
+ # Category -> quality axis (intelligence|taste) for this consultation.
105
+ local cat axis
106
+ cat=$(jq -r '.quality_metrics.category // empty' "$dir/optimization_metrics.json" 2>/dev/null)
107
+ [[ -z "$cat" ]] && cat="GENERAL"
108
+ axis=$(get_category_axis "$cat")
109
+
110
+ # Peer-review quality scores (blind) -> quality accumulator.
111
+ local peer_file
112
+ if peer_file=$(_find_peer_file "$dir"); then
113
+ while read -r c s; do
114
+ [[ -z "$c" || "$c" == "unknown" ]] && continue
115
+ echo "$c $axis $s" >> "$qacc"
116
+ done < <(jq -r '.[] | "\(.consultant) \(.average_peer_score)"' "$peer_file" 2>/dev/null)
117
+ used=$((used+1))
118
+ fi
119
+
120
+ # Observed cost per response -> cost accumulator.
121
+ local f
122
+ for f in "$dir"/*.json; do
123
+ [[ -f "$f" && -s "$f" ]] || continue
124
+ local c model tokens
125
+ c=$(jq -r '.consultant // empty' "$f" 2>/dev/null)
126
+ [[ -z "$c" ]] && continue
127
+ model=$(jq -r '.model // "unknown"' "$f" 2>/dev/null)
128
+ tokens=$(jq -r '.metadata.tokens_used // empty' "$f" 2>/dev/null)
129
+ [[ -z "$tokens" || ! "$tokens" =~ ^[0-9]+$ ]] && continue
130
+ echo "$c $(_response_cost "$model" "$tokens")" >> "$cacc"
131
+ done
132
+ done
133
+
134
+ if [[ $used -eq 0 ]]; then
135
+ echo "roster_calibrate: no consultations with peer-review data found (run with ENABLE_PEER_REVIEW=true)" >&2
136
+ exit 1
137
+ fi
138
+
139
+ # --- Aggregate quality per (consultant, axis): mean, rounded, clamped 1-10 ---
140
+ # Emits: "consultant intelligence <score|-> <n> taste <score|-> <n>"
141
+ local quality_rows
142
+ quality_rows=$(awk '
143
+ { key=$1 SUBSEP $2; sum[key]+=$3; n[key]++; seen[$1]=1 }
144
+ END {
145
+ for (c in seen) {
146
+ ik=c SUBSEP "intelligence"; tk=c SUBSEP "taste"
147
+ iv=(ik in sum)?int(sum[ik]/n[ik]+0.5):-1; ic=(ik in n)?n[ik]:0
148
+ tv=(tk in sum)?int(sum[tk]/n[tk]+0.5):-1; tc=(tk in n)?n[tk]:0
149
+ if (iv>10) iv=10; if (iv!=-1 && iv<1) iv=1
150
+ if (tv>10) tv=10; if (tv!=-1 && tv<1) tv=1
151
+ printf "%s %s %d %s %d\n", c, (iv==-1?"-":iv), ic, (tv==-1?"-":tv), tc
152
+ }
153
+ }' "$qacc" | sort)
154
+
155
+ # --- Aggregate cost per consultant: mean, then rank-normalize (cheapest=10) ---
156
+ # Emits: "consultant <costscore> <n>"
157
+ local cost_rows
158
+ cost_rows=$(awk '
159
+ { sum[$1]+=$2; n[$1]++ }
160
+ END { for (c in sum) printf "%s %.10f %d\n", c, sum[c]/n[c], n[c] }
161
+ ' "$cacc" | sort -k2,2g | awk '
162
+ { c[NR]=$1; mean[NR]=$2; cnt[NR]=$3 }
163
+ END {
164
+ N=NR
165
+ for (r=1; r<=N; r++) {
166
+ if (N==1) score=5
167
+ else score=int(1 + 9*(N-r)/(N-1) + 0.5) # rank 1 (cheapest) -> 10
168
+ print c[r], score, cnt[r]
169
+ }
170
+ }' | sort)
171
+
172
+ # --- Assemble measured_capabilities + samples JSON ---
173
+ local caps='{}' samples='{}' c iv ic tv tc
174
+ while read -r c iv ic tv tc; do
175
+ [[ -z "$c" ]] && continue
176
+ caps=$(jq -c --arg c "$c" \
177
+ --argjson iv "$([[ "$iv" == "-" ]] && echo null || echo "$iv")" \
178
+ --argjson tv "$([[ "$tv" == "-" ]] && echo null || echo "$tv")" \
179
+ '.[$c] = ((.[$c] // {}) + (if $iv==null then {} else {intelligence:$iv} end)
180
+ + (if $tv==null then {} else {taste:$tv} end))' <<<"$caps")
181
+ samples=$(jq -c --arg c "$c" --argjson ic "$ic" --argjson tc "$tc" \
182
+ '.[$c] = ((.[$c] // {}) + {intelligence_n:$ic, taste_n:$tc})' <<<"$samples")
183
+ done <<< "$quality_rows"
184
+
185
+ while read -r c score cnt; do
186
+ [[ -z "$c" ]] && continue
187
+ caps=$(jq -c --arg c "$c" --argjson v "$score" '.[$c] = ((.[$c] // {}) + {cost:$v})' <<<"$caps")
188
+ samples=$(jq -c --arg c "$c" --argjson n "$cnt" '.[$c] = ((.[$c] // {}) + {cost_n:$n})' <<<"$samples")
189
+ done <<< "$cost_rows"
190
+
191
+ local result
192
+ result=$(jq -n --argjson caps "$caps" --argjson samples "$samples" \
193
+ '{measured_capabilities:$caps, samples:$samples}')
194
+
195
+ if [[ "$do_write" -eq 1 ]]; then
196
+ cp "$AFFINITY_JSON" "${AFFINITY_JSON}.bak"
197
+ jq --argjson m "$caps" '.capabilities = ((.capabilities // {}) * $m)' "$AFFINITY_JSON" \
198
+ > "${AFFINITY_JSON}.tmp" && mv "${AFFINITY_JSON}.tmp" "$AFFINITY_JSON"
199
+ echo "Merged measured capabilities into $AFFINITY_JSON (backup: ${AFFINITY_JSON}.bak)"
200
+ echo "Measured $(echo "$caps" | jq 'length') consultant(s) from $used consultation(s)."
201
+ elif [[ "$as_json" -eq 1 ]]; then
202
+ echo "$result"
203
+ else
204
+ echo "Measured capabilities from $used consultation(s) with peer-review data:"
205
+ echo "(intelligence/taste = mean blind peer score on that axis's categories; cost = rank, cheapest=10)"
206
+ echo
207
+ echo "$result" | jq -r '
208
+ .measured_capabilities as $c | .samples as $s
209
+ | ($c | keys[]) as $k
210
+ | " \($k): intelligence=\($c[$k].intelligence // "n/a") taste=\($c[$k].taste // "n/a") cost=\($c[$k].cost // "n/a")"
211
+ + " (n: int=\($s[$k].intelligence_n // 0) taste=\($s[$k].taste_n // 0) cost=\($s[$k].cost_n // 0))"'
212
+ echo
213
+ echo "Apply with --write (merges into affinity.json, keeping unmeasured cells), or --json for raw."
214
+ fi
215
+ }
216
+
217
+ main "$@"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # run_calibration.sh — data-collection harness for roster_calibrate.sh.
3
+ #
4
+ # Runs each benchmark question (references/calibration_benchmark.json) through
5
+ # the full panel with ENABLE_PEER_REVIEW=true, collects the output dirs, then
6
+ # calibrates measured intelligence/taste/cost scores.
7
+ #
8
+ # WARNING: this makes real consultant calls for EVERY question — it costs money
9
+ # and takes time (dozens of consultations). Use --limit to sample first and
10
+ # --dry-run to preview without running anything.
11
+ #
12
+ # Usage: run_calibration.sh [--limit N] [--dry-run] [--write]
13
+ # --limit N only run the first N benchmark questions
14
+ # --dry-run print the plan; execute nothing
15
+ # --write after collecting, merge measured scores into affinity.json
16
+ # -h, --help show this header
17
+ set -uo pipefail
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ BENCH="$SCRIPT_DIR/../references/calibration_benchmark.json"
21
+
22
+ main() {
23
+ local limit=0 dry=0 do_write=0
24
+ while [[ $# -gt 0 ]]; do
25
+ case "$1" in
26
+ --limit) limit="${2:-0}"; shift 2 ;;
27
+ --dry-run) dry=1; shift ;;
28
+ --write) do_write=1; shift ;;
29
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
30
+ *) echo "unknown option: $1" >&2; exit 2 ;;
31
+ esac
32
+ done
33
+
34
+ command -v jq >/dev/null 2>&1 || { echo "run_calibration: jq is required" >&2; exit 2; }
35
+ [[ -f "$BENCH" ]] || { echo "run_calibration: benchmark not found: $BENCH" >&2; exit 1; }
36
+
37
+ local total
38
+ total=$(jq '.questions | length' "$BENCH")
39
+ [[ "$limit" -gt 0 && "$limit" -lt "$total" ]] && total="$limit"
40
+
41
+ echo "Calibration run: $total question(s) x full panel, ENABLE_PEER_REVIEW=true."
42
+ [[ "$dry" -eq 1 ]] && echo "(dry run — nothing will be executed)"
43
+ echo
44
+
45
+ local dirs=() i=0 q qcat outdir
46
+ while IFS= read -r q; do
47
+ [[ "$limit" -gt 0 && "$i" -ge "$limit" ]] && break
48
+ i=$((i+1))
49
+ qcat=$(jq -r ".questions[$((i-1))].category" "$BENCH")
50
+ printf '[%d/%d] %-12s %.70s\n' "$i" "$total" "$qcat" "$q"
51
+ [[ "$dry" -eq 1 ]] && continue
52
+ outdir=$(ENABLE_PEER_REVIEW=true "$SCRIPT_DIR/consult_all.sh" "$q" 2>/dev/null | tail -1)
53
+ if [[ -n "$outdir" && -d "$outdir" ]]; then
54
+ dirs+=("$outdir")
55
+ else
56
+ echo " ! consultation produced no output dir — skipped"
57
+ fi
58
+ done < <(jq -r '.questions[].q' "$BENCH")
59
+
60
+ if [[ "$dry" -eq 1 ]]; then
61
+ echo; echo "Dry run complete. Re-run without --dry-run to collect data."
62
+ exit 0
63
+ fi
64
+
65
+ if [[ ${#dirs[@]} -eq 0 ]]; then
66
+ echo "No consultations succeeded — nothing to calibrate. Check ./scripts/doctor.sh --live." >&2
67
+ exit 1
68
+ fi
69
+
70
+ echo; echo "Collected ${#dirs[@]} consultation(s). Calibrating..."
71
+ local calib_args=()
72
+ [[ "$do_write" -eq 1 ]] && calib_args+=(--write)
73
+ "$SCRIPT_DIR/roster_calibrate.sh" ${calib_args[@]+"${calib_args[@]}"} "${dirs[@]}"
74
+ }
75
+
76
+ main "$@"