1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
//! `mecha corroborate` — does a generalisation hold beyond the one
//! transcript it came from?
//!
//! The review queue is not one problem. Clustered by (proposer, predicate)
//! it splits into classes that fail in structurally different ways, and the
//! design variable between them is not the prompt — it is what makes the
//! two readers independent.
//!
//! Here the axis is SOURCE. `bee:suggested·related_to` holds 300 behavioural
//! generalisations drawn from single conversations — "Luke prefers DIY
//! approaches over formal design consultation" — every one at confidence
//! 0.50 with no verdict history at all. The question they need is the one
//! gossip can actually ask: does anything else in the graph show this, or is
//! it one transcript over-generalised?
//!
//! That is a bounded accept-or-reject judgement over an enumerated
//! candidate, which is a far safer operation than assertion. Nothing is
//! generated; nothing new can be hallucinated into the graph. The two
//! readers only report what their own evidence shows, and the verdict is
//! computed from their two answers in code.
use anyhow::{bail, Context, Result};
use clap::Args;
use mecha_core::config::Config;
use mecha_core::gossip::{self, ReaderSetup, Vantage};
use mecha_core::tool::ToolCtx;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Args, Debug)]
pub struct CorroborateArgs {
/// Proposer of the class to work, e.g. `bee:suggested`.
#[arg(long, default_value = "bee:suggested")]
pub proposer: String,
/// Predicate of the class to work, e.g. `related_to`.
#[arg(long, default_value = "related_to")]
pub predicate: String,
/// Candidates to judge, oldest first.
#[arg(long, default_value_t = 10)]
pub limit: usize,
/// Evidence on/after this date. Both readers get the same window: here
/// a difference between them must be the SOURCES disagreeing, not the
/// world having moved. (The persistence pattern inverts this — there,
/// the world having moved is the signal.)
#[arg(long, default_value = "2024-01-01")]
pub since: String,
#[arg(long, default_value = "graph")]
pub server: String,
/// Minimum episodes a source needs before it can be a vantage.
#[arg(long, default_value_t = 3)]
pub min_coverage: i64,
/// File the verdicts beside their candidates. Off by default: a verdict
/// decides nothing, but a store filling up with an unmeasured
/// mechanism's opinions is still worth opting into rather than
/// inheriting.
#[arg(long)]
pub record: bool,
/// Append one JSON line per judged candidate — verdict, both sightings
/// with citations, and the dissenter's pre-reveal answer. The rule this
/// mechanism exists to derive gets derived from these transcripts;
/// stdout alone leaves nothing to derive from.
#[arg(long)]
pub out: Option<std::path::PathBuf>,
}
pub async fn run(global: &crate::GlobalOpts, args: &CorroborateArgs) -> Result<()> {
let cwd = std::env::current_dir().context("cannot determine the working directory")?;
let cfg = Config::load(&cwd)?;
let Some(server_cfg) = cfg.mcp.iter().find(|c| c.name == args.server) else {
bail!("no [[mcp]] server named '{}' in config", args.server);
};
let sandbox = mecha_core::sandbox::Sandbox::new(cfg.sandbox.clone());
let client = Arc::new(
mecha_core::mcp::McpClient::connect(server_cfg, &sandbox, &cwd)
.await
.with_context(|| format!("connecting to MCP server '{}'", args.server))?,
);
// Skip what corroboration already judged: each run extends coverage.
let candidates = gossip::pending(
&client,
&args.proposer,
&args.predicate,
args.limit,
Some("corroboration"),
false,
)
.await?;
if candidates.is_empty() {
println!("nothing pending in {}·{}", args.proposer, args.predicate);
return Ok(());
}
let (provider_name, provider_cfg) = cfg.provider(global.provider.as_deref())?;
let model = global.model.clone().or_else(|| provider_cfg.model.clone());
let tool_ctx = ToolCtx {
workspace: cwd.clone(),
security: cfg.security.clone(),
..ToolCtx::default()
};
let approver = Arc::new(mecha_core::tool::ModeApprover {
mode: mecha_core::config::PermissionMode::ReadOnly,
});
let cx = mecha_core::agent::RunContext::new(tool_ctx.clone(), approver);
let until = chrono::Local::now().format("%Y-%m-%d").to_string();
println!(
"corroborating {} candidate(s) from {}·{} · {}..{} · {} ({provider_name})\n",
candidates.len(),
args.proposer,
args.predicate,
args.since,
until,
model.as_deref().unwrap_or("default model"),
);
// Coverage is per SUBJECT, and most of a class shares one — cache it
// rather than asking the graph the same question 300 times.
let mut coverage_cache: HashMap<String, Vec<gossip::SourceCoverage>> = HashMap::new();
let mut tally: HashMap<&str, usize> = HashMap::new();
let mut out_file = match &args.out {
Some(p) => Some(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)
.with_context(|| format!("opening --out file {}", p.display()))?,
),
None => None,
};
for cand in &candidates {
let subject = cand.subject.clone().unwrap_or_default();
if subject.is_empty() {
println!(
" [skipped] {} — no subject to measure coverage for",
cand.candidate_id
);
*tally.entry("skipped").or_default() += 1;
continue;
}
let cov = match coverage_cache.get(&subject) {
Some(c) => c.clone(),
None => {
// coverage_best, not coverage: a bare "Luke" is ambiguous
// while the graph holds two of him, and stopping there made
// every claim about the graph's owner unjudgeable.
let c = match gossip::coverage_best(&client, &subject).await {
Ok((name, sources, guessed)) if !name.is_empty() => {
if guessed {
println!(" (coverage for '{subject}' measured on '{name}' — the name matches more than one node)");
}
gossip::windowed_coverage(&client, &name, &sources, &args.since, &until)
.await
.unwrap_or_default()
}
_ => vec![],
};
coverage_cache.insert(subject.clone(), c.clone());
c
}
};
let Some((va, vb)) =
// Fall back to the proposer when there is no originating
// episode: `bee:suggested` still says the claim came from Bee.
gossip::vantages_excluding(
&cov,
cand.origin_source.as_deref().or(Some(&args.proposer)),
args.min_coverage,
)
else {
// Two different failures, and conflating them misreported the
// first live control run: an empty coverage list means the
// subject could not be resolved at all, NOT that its origin is
// the only witness.
let why = if cov.is_empty() {
format!("'{subject}' does not resolve to anything the graph has coverage for")
} else {
format!(
"nothing outside {} covers '{subject}'; a claim cannot corroborate itself",
cand.origin_source.as_deref().unwrap_or(&args.proposer),
)
};
println!(
" [no-witness] {} — {why}\n {}",
cand.candidate_id, cand.statement,
);
*tally.entry("no_witness").or_default() += 1;
continue;
};
let build = |v: &Vantage| -> Result<mecha_core::agent::Agent> {
gossip::reader(
mecha_core::provider::build(provider_cfg)?,
ReaderSetup {
client: Arc::clone(&client),
vantage: v.clone(),
since: args.since.clone(),
until: until.clone(),
tool_ctx: tool_ctx.clone(),
agent_cfg: cfg.agent.clone(),
model: model.clone(),
system_prompt: gossip::SIGHT_SYS.to_string(),
},
)
};
let readers = vec![(va.clone(), build(&va)?), (vb.clone(), build(&vb)?)];
// One candidate's failure must not abort the batch: an MCP hiccup on
// candidate 150 used to lose everything after it.
let result = match gossip::corroborate(&readers, &cx, cand).await {
Ok(r) => r,
Err(e) => {
println!(" [error] {} — {e:#}\n", cand.candidate_id);
*tally.entry("error").or_default() += 1;
continue;
}
};
*tally.entry(result.verdict).or_default() += 1;
println!(" [{}] {}", result.verdict, result.statement);
if cand.subject_ambiguous {
println!(" (subject '{subject}' guessed from an ambiguous name — a duplicate identity upstream)");
}
for (who, sighting, cite) in &result.sightings {
println!(" {who}: {sighting:?} — {cite}");
}
if let Some((who, first, _)) = &result.pre_reveal {
println!(" ({who} first said {first:?}, then looked again after seeing the other's citation)");
}
if args.record {
let basis = result
.sightings
.iter()
.map(|(w, s, _)| format!("{w}:{s:?}"))
.collect::<Vec<_>>()
.join(" ");
if let Err(e) = gossip::file_verdict(
&client,
result.candidate_id,
"corroboration",
result.verdict,
&basis,
model.as_deref(),
)
.await
{
println!(" (verdict not filed: {e:#})");
*tally.entry("file_error").or_default() += 1;
}
}
if let Some(f) = out_file.as_mut() {
use std::io::Write;
let line = serde_json::json!({
"at": chrono::Local::now().to_rfc3339(),
"proposer": args.proposer,
"predicate": args.predicate,
"subject": subject,
"subject_ambiguous": cand.subject_ambiguous,
"origin_source": cand.origin_source,
"vantages": [&va, &vb],
"since": args.since,
"until": until,
"model": model,
"recorded": args.record,
"result": result,
});
writeln!(f, "{line}").context("writing --out line")?;
}
println!();
}
let mut counts: Vec<(&&str, &usize)> = tally.iter().collect();
counts.sort_by_key(|(k, _)| **k);
println!(
"— {}",
counts
.iter()
.map(|(k, n)| format!("{n} {k}"))
.collect::<Vec<_>>()
.join(", ")
);
if args.record {
println!("Verdicts filed. Every candidate is still pending: a verdict is an opinion, and the decision is yours.");
} else {
println!(
"Nothing was written. Re-run with --record to file these beside their candidates."
);
}
Ok(())
}