feat(research): draft placeholder refs ({} / null) read as open slots in the unwired probe

A campaign draft can now validate-so-far AND report its open ref slots
(0106 fieldtest F4): open_slots_campaign treats an absent ref, {} and
null uniformly as the open slot (strategies[i].ref with the two-arm
hint; process.ref with the content-id-only hint). Draft affordance of
the probe only — the strict parse tier keeps refusing placeholders (a
placeholder is not a valid document form; canonical bytes stay
untouched).

RED-first (tdd-author handoff): library pin + CLI --unwired e2e
observed failing (probe reported 'no open slots' over placeholder
refs), then the two ref predicates routed through ref_slot_is_open.

Gates: workspace 995/0, clippy -D warnings clean.

closes #195
This commit is contained in:
2026-07-03 23:07:10 +02:00
parent 63d2657850
commit 1d87397c95
2 changed files with 75 additions and 2 deletions
+27
View File
@@ -421,6 +421,33 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
}
/// #195 (fieldtest 0106 F4): the consumer view of the placeholder-ref probe.
/// A draft that spells its open refs as `"ref": {}` / `"ref": null` — the
/// natural drafting placeholders — has `--unwired` enumerate BOTH as open slots
/// (exit 0), instead of the old cryptic serde refusal / silent "no open slots".
#[test]
fn campaign_introspect_unwired_reports_placeholder_refs() {
let dir = temp_cwd("campaign-introspect-unwired-placeholder");
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": null,
"axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ],
"process": { "ref": {} },
"seed": 1,
"presentation": { "persist_taps": [], "emit": [] } }"#;
write_doc(&dir, "draft.campaign.json", draft);
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(
out.contains("open slot: strategies[0].ref (required, one of: content_id, identity_id)"),
"strategy `ref: null` placeholder not enumerated: {out}"
);
assert!(
out.contains("open slot: process.ref (required, content id of a process document)"),
"process `ref: {{}}` placeholder not enumerated: {out}"
);
}
/// The campaign twin of `process_introspect_vocabulary_block_and_content_id`'s
/// `--content-id` branch: pins that `campaign introspect --content-id` wires
/// `parse_valid_campaign` + `campaign_to_json` + `content_id_of` end to end
+48 -2
View File
@@ -836,6 +836,18 @@ pub fn open_slots_process(text: &str) -> Result<Vec<OpenSlot>, DocError> {
}
/// List the open slots of a (possibly partial) campaign document.
/// Whether a `ref` slot in a DRAFT reads as open: absent, or one of the two
/// natural placeholder spellings `{}` / `null` (#195). A draft affordance of
/// the open-slot probe only — the strict parse tier keeps refusing
/// placeholders (they are not a valid document form).
fn ref_slot_is_open(r: Option<&serde_json::Value>) -> bool {
match r {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::Object(m)) => m.is_empty(),
Some(_) => false,
}
}
pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?;
@@ -862,7 +874,7 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
}
Some(list) => {
for (i, s) in list.iter().enumerate() {
if s.get("ref").is_none() {
if ref_slot_is_open(s.get("ref")) {
slots.push(open(
format!("strategies[{i}].ref"),
"required, one of: content_id, identity_id",
@@ -891,7 +903,7 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
}
}
}
if v.get("process").and_then(|p| p.get("ref")).is_none() {
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
// deliberately narrower than the strategy-ref hint: validate_campaign
// refuses an identity_id process ref, so the guide must not offer it
slots.push(open("process.ref", "required, content id of a process document"));
@@ -1402,4 +1414,38 @@ mod tests {
assert!(matches!(open_slots_process("nope"), Err(DocError::NotJson(_))));
}
/// Property: in the open-slot probe, a ref placeholder — `"ref": {}` or
/// `"ref": null` — is a drafting affordance that reports the SAME open slot
/// (same path, same existing hint) the omitted-key form reports, on both a
/// strategy entry and `process.ref`, and never aborts the probe. It lets one
/// draft file both validate-so-far and enumerate its open slots (fieldtest
/// 0106 F4 / #195). This pins only the probe; the strict `parse_campaign`
/// tier keeps refusing placeholders (canonical-form byte stability outranks
/// draft leniency) and is deliberately not exercised here.
#[test]
fn open_slots_report_placeholder_refs_as_open_slots() {
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": null,
"axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ],
"process": { "ref": {} },
"seed": 1,
"presentation": { "persist_taps": [], "emit": [] } }"#;
// Errors on neither placeholder form (the probe stays a probe).
let slots = open_slots_campaign(draft).expect("placeholder refs must not abort the probe");
// The `null` strategy ref reports the strategy-ref open slot with its
// existing both-id-forms hint...
assert!(
slots.iter().any(|s| s.path == "strategies[0].ref"
&& s.hint == "required, one of: content_id, identity_id"),
"strategy `ref: null` placeholder not reported as an open slot: {slots:?}"
);
// ...and the `{}` process ref reports the narrower content-id-only hint.
assert!(
slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"),
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
);
}
}