refactor(construction_pattern): Phase B language-advantage pass — dichotomy wave
CI Pipeline / rust-surface (push) Failing after 13s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / go-surface (push) Canceled after 16s
CI Pipeline / best-practices (push) Canceled after 3s
CI Pipeline / canary (push) Canceled after 16s
Auto-version and Release / release (push) Canceled after 22s
deploy / deploy (push) Canceled after 26s
CI Pipeline / lint (push) Canceled after 23s
CI Pipeline / unit-tests (push) Canceled after 23s

language advantage: ApplicabilityVerdict/ObligationVerdict enums replace
string-soup verdicts, serialized at the JSON boundary via exhaustive
as_str matches; language advantage: obligation when-conditions classify
into a WhenShape enum (Unconditioned/Always/PathContains/
EntitiesMatch/ContractsTouched/Unrecognized) dispatched exhaustively —
the unrecognized shape surfaces as UNKNOWN, never silently REQUIRED;
language advantage: str_list borrows slices of the input JSON (owned
copies only where the output needs them), derive reuses the obligation
object instead of cloning it, zero-copy split_tokens returns
token slices borrowing the lowered input (re.split leading/trailing
empty fidelity kept); language advantage: contracts_match evidence and
when-condition hits built as iterator chains; sort_unstable throughout.

Battery 9/9 (56 frozen oracle cases) green before and after the pass;
clippy 0; live ctypes parity 56/56; construction surface tests 51/51
green with RUNE_CP_CORE=python pin and with the kernel live.
This commit is contained in:
Nick committed 2026-10-11 21:51:05 +01:00
1 parent 681312c69f
commit c42936b959
2 files changed
+257 -176

No files matched your search

@@ -61,8 +61,8 @@ mod battery {
}
return Ok(Value::Array(out));
}
let evidence: Vec<String> =
i["evidence"].as_array().map(|a| a.iter().filter_map(|e| e.as_str().map(String::from)).collect()).unwrap_or_default();
let evidence: Vec<&str> =
i["evidence"].as_array().map(|a| a.iter().filter_map(|e| e.as_str()).collect()).unwrap_or_default();
record_pattern_proposal(
i["pattern_id"].as_str().unwrap_or(""),
i["provider"].as_str().unwrap_or(""),
+255 -174
View File
@@ -21,20 +21,101 @@
//! The boundary-aware matchers are the oracle-verified kernels promoted by
//! batch 155 (the 'ui'-in-'builtin' false-positive class is blocked by
//! design): short (<4 char, char-counted) signals match whole tokens only.
//!
//! Language-advantage pass (Phase B, battery-pinned): the verdict lattices
//! are enums with exhaustive `as_str` matches at the JSON boundary; the
//! obligation `when`-condition is classified into a `WhenShape` enum and
//! dispatched exhaustively (no string-soup if-chains); signal/path/entity
//! lists borrow slices of the input JSON instead of cloning; tokenization
//! is zero-copy over the lowered input.
use serde_json::{json, Map, Value};
// ── verdict lattices (string constants, oracle-identical) ─────────────────
// ── verdict lattices (enums with exhaustive matches at the JSON boundary) ──
pub const APPLIES: &str = "APPLIES";
pub const PARTIAL: &str = "PARTIAL";
pub const DOES_NOT_APPLY: &str = "DOES_NOT_APPLY";
pub const UNKNOWN: &str = "UNKNOWN";
/// Applicability verdict lattice (§4-5): APPLIES / PARTIAL /
/// DOES_NOT_APPLY / UNKNOWN. UNKNOWN never becomes APPLIES because a name
/// sounds similar.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ApplicabilityVerdict {
Applies,
Partial,
DoesNotApply,
Unknown,
}
pub const OBL_REQUIRED: &str = "REQUIRED";
pub const OBL_APPLICABLE: &str = "APPLICABLE";
pub const OBL_NOT_APPLICABLE: &str = "NOT_APPLICABLE";
pub const OBL_UNKNOWN: &str = "UNKNOWN";
impl ApplicabilityVerdict {
fn as_str(self) -> &'static str {
match self {
ApplicabilityVerdict::Applies => "APPLIES",
ApplicabilityVerdict::Partial => "PARTIAL",
ApplicabilityVerdict::DoesNotApply => "DOES_NOT_APPLY",
ApplicabilityVerdict::Unknown => "UNKNOWN",
}
}
}
/// Obligation `when`-condition verdict lattice (§6-7): REQUIRED /
/// APPLICABLE / NOT_APPLICABLE / UNKNOWN — an obligation with no
/// recognized `when` is UNKNOWN (never silently required).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ObligationVerdict {
Required,
Applicable,
NotApplicable,
Unknown,
}
impl ObligationVerdict {
fn as_str(self) -> &'static str {
match self {
ObligationVerdict::Required => "REQUIRED",
ObligationVerdict::Applicable => "APPLICABLE",
ObligationVerdict::NotApplicable => "NOT_APPLICABLE",
ObligationVerdict::Unknown => "UNKNOWN",
}
}
}
/// The `when`-condition shapes (all deterministic; §6-7). Classified once,
/// dispatched exhaustively — a shape outside the lattice is Unrecognized
/// and surfaces as UNKNOWN, never REQUIRED.
enum WhenShape<'a> {
Unconditioned,
Always,
PathContains(&'a Value),
EntitiesMatch(&'a Value),
ContractsTouched(&'a Value),
Unrecognized(Vec<String>),
}
fn when_shape(obligation: &Map<String, Value>) -> WhenShape<'_> {
match obligation.get("when") {
None | Some(Value::Null) => WhenShape::Unconditioned,
Some(w) => {
if w.get("always").and_then(|v| v.as_bool()).unwrap_or(false) {
return WhenShape::Always;
}
// Python checks KEY PRESENCE (`"path_contains" in when`), so a
// null value still enters the branch — `get` mirrors that.
if let Some(subs) = w.get("path_contains") {
return WhenShape::PathContains(subs);
}
if let Some(subs) = w.get("entities_match") {
return WhenShape::EntitiesMatch(subs);
}
if let Some(wanted) = w.get("contracts_touched") {
return WhenShape::ContractsTouched(wanted);
}
match w {
Value::Object(m) => WhenShape::Unrecognized(m.keys().cloned().collect()),
// Python crashes on `when.get` before reaching the
// sorted-shape message for non-dict `when` — unreachable.
_ => WhenShape::Unrecognized(vec![]),
}
}
}
}
const KNOWN_KEYS: &[&str] = &[
"pattern_id",
@@ -91,8 +172,8 @@ fn py_repr(v: &Value) -> String {
}
}
fn py_str_list(items: &[String]) -> String {
let inner: Vec<String> = items.iter().map(|s| py_quote(s)).collect();
fn py_str_list<T: AsRef<str>>(items: &[T]) -> String {
let inner: Vec<String> = items.iter().map(|s| py_quote(s.as_ref())).collect();
format!("[{}]", inner.join(", "))
}
@@ -100,23 +181,39 @@ fn py_str_list(items: &[String]) -> String {
/// Oracle-faithful token split: runs of non-token chars are ONE separator,
/// with leading/trailing empties kept (`re.split` semantics — only the
/// empties are observable, via the empty-signal quirk).
fn split_tokens(lowered: &str, keep_colon: bool) -> Vec<String> {
/// empties are observable, via the empty-signal quirk). Zero-copy: tokens
/// borrow slices of the lowered input.
fn split_tokens<'a>(lowered: &'a str, keep_colon: bool) -> Vec<&'a str> {
let is_tok = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || (keep_colon && c == ':');
let mut out = Vec::new();
let mut cur = String::new();
let mut pending_empty = true;
for c in lowered.chars() {
if lowered.is_empty() {
return vec![""];
}
let mut toks: Vec<&'a str> = Vec::new();
let mut start: Option<usize> = None;
let mut ended_with_sep = false;
for (i, c) in lowered.char_indices() {
if is_tok(c) {
cur.push(c);
pending_empty = false;
} else if !cur.is_empty() || pending_empty {
out.push(std::mem::take(&mut cur));
pending_empty = false;
if start.is_none() {
start = Some(i);
}
ended_with_sep = false;
} else {
if let Some(s) = start.take() {
toks.push(&lowered[s..i]);
}
ended_with_sep = true;
}
}
out.push(cur);
out
if let Some(s) = start {
toks.push(&lowered[s..]);
}
if lowered.starts_with(|c: char| !is_tok(c)) {
toks.insert(0, "");
}
if ended_with_sep {
toks.push("");
}
toks
}
/// Boundary-aware path-signal matching: short (<4 char) signals match path
@@ -127,7 +224,7 @@ pub fn path_matches(signal: &str, path: &str) -> bool {
if s.chars().count() >= 4 {
return path.to_lowercase().contains(&s);
}
split_tokens(&path.to_lowercase(), false).iter().any(|t| t == &s)
split_tokens(&path.to_lowercase(), false).iter().any(|t| *t == s)
}
/// Same discipline for entity ids. Prefix signals ending in ':' (e.g.
@@ -142,20 +239,19 @@ pub fn entity_matches(signal: &str, entity_id: &str) -> bool {
if s.chars().count() >= 4 {
return entity_id.to_lowercase().contains(&s);
}
split_tokens(&entity_id.to_lowercase(), true).iter().any(|t| t == &s)
split_tokens(&entity_id.to_lowercase(), true).iter().any(|t| *t == s)
}
// ── small JSON accessors ──────────────────────────────────────────────────
// ── small JSON accessors (borrowed; owned only where the output needs it) ──
fn str_list(v: &Value) -> Vec<String> {
fn str_list(v: &Value) -> Vec<&str> {
v.as_array()
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default()
}
fn sorted_strings(mut v: Vec<String>) -> Vec<String> {
v.sort();
v
fn str_list_owned(v: &Value) -> Vec<String> {
str_list(v).into_iter().map(String::from).collect()
}
/// `ss.relationships(doc, from_id=e)` — including the falsy-filter quirk:
@@ -216,8 +312,9 @@ pub fn construct_pattern(input: &Map<String, Value>) -> Result<Map<String, Value
input.get("obligations").cloned().unwrap_or_else(|| Value::Array(vec![])),
);
for key in ["provenance", "exclusions", "toolchain_notes", "runtime_effects"] {
let vals = input.get(key).map(str_list).unwrap_or_default();
out.insert(key.into(), json!(sorted_strings(vals)));
let mut vals = input.get(key).map(str_list_owned).unwrap_or_default();
vals.sort_unstable();
out.insert(key.into(), json!(vals));
}
for (k, v) in input.iter() {
if !KNOWN_KEYS.contains(&k.as_str()) {
@@ -246,9 +343,9 @@ pub fn revise_pattern(
}
merged.insert("revision".into(), json!(revision + 1));
let mut ev: Vec<String> = evidence.iter().filter_map(|e| e.as_str().map(String::from)).collect();
ev.sort();
ev.sort_unstable();
let mut changed: Vec<String> = changes.map(|ch| ch.keys().cloned().collect()).unwrap_or_default();
changed.sort();
changed.sort_unstable();
let entry = json!({
"prior_revision": revision,
"reason": reason.chars().take(200).collect::<String>(),
@@ -272,7 +369,7 @@ pub fn record_pattern_proposal(
pattern_id: &str,
provider: &str,
model: &str,
evidence: Vec<String>,
evidence: Vec<&str>,
confidence: f64,
) -> Result<Value, String> {
if provider.is_empty() || model.is_empty() {
@@ -281,12 +378,14 @@ pub fn record_pattern_proposal(
if !(0.0..=1.0).contains(&confidence) {
return Err("confidence must be in [0,1]".to_string());
}
let mut ev = evidence;
ev.sort_unstable();
Ok(json!({
"kind": "pattern_proposal",
"pattern_id": pattern_id,
"provider": provider,
"model": model,
"evidence": sorted_strings(evidence),
"evidence": ev,
"confidence": confidence,
"verification_state": "PENDING",
}))
@@ -319,113 +418,93 @@ pub fn evaluate_applicability(
surface: &Map<String, Value>,
authorities: &Map<String, Value>,
) -> Value {
let pattern_id = pattern.get("pattern_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let objective = need.get("objective").and_then(|v| v.as_str()).unwrap_or("").to_string();
let exclusions = pattern.get("exclusions").map(str_list).unwrap_or_default();
let need_exclusions = need
let pattern_id = pattern.get("pattern_id").and_then(|v| v.as_str()).unwrap_or("");
let objective = need.get("objective").and_then(|v| v.as_str()).unwrap_or("");
let exclusions: Vec<&str> = pattern.get("exclusions").map(str_list).unwrap_or_default();
let need_exclusions: Vec<&str> = need
.get("constraints")
.and_then(|c| c.get("pattern_exclusions"))
.map(str_list)
.unwrap_or_default();
// §3/§18: PROJECT evidence overrides the pattern.
let mut declared = exclusions;
declared.extend(need_exclusions.clone());
let objective_low = objective.to_lowercase();
for ex in &declared {
if objective_low.contains(&ex.to_lowercase()) || need_exclusions.iter().any(|x| x == ex) {
for ex in exclusions.iter().copied().chain(need_exclusions.iter().copied()) {
if objective_low.contains(&ex.to_lowercase()) || need_exclusions.contains(&ex) {
return json!({
"pattern": pattern_id,
"verdict": DOES_NOT_APPLY,
"verdict": ApplicabilityVerdict::DoesNotApply.as_str(),
"reason": format!("declared exclusion matched: {ex}"),
"evidence": {"exclusion": ex, "source": "project/need declaration"},
});
}
}
let signals = pattern.get("signals").and_then(|v| v.as_object()).cloned().unwrap_or_default();
if signals.is_empty() {
return json!({
"pattern": pattern_id,
"verdict": UNKNOWN,
"reason": "pattern declares no applicability signals — nothing deterministic to check",
"evidence": {},
});
}
let signals = match pattern.get("signals").and_then(|v| v.as_object()) {
Some(s) if !s.is_empty() => s,
_ => {
return json!({
"pattern": pattern_id,
"verdict": ApplicabilityVerdict::Unknown.as_str(),
"reason": "pattern declares no applicability signals — nothing deterministic to check",
"evidence": {},
});
}
};
let mut groups_hit: Vec<String> = Vec::new();
let mut groups_declared: i64 = 0;
let mut groups_hit: Vec<&'static str> = Vec::new();
let mut groups_declared: usize = 0;
let mut evidence = Map::new();
let paths: Vec<String> = surface
let paths: Vec<&str> = surface
.get("affected_paths")
.map(str_list)
.unwrap_or_default()
.into_iter()
.chain(surface.get("workflow_config_surfaces").map(str_list).unwrap_or_default())
.collect();
let entities: Vec<String> =
surface.get("affected_existing_entities").map(str_list).unwrap_or_default();
let entities: Vec<&str> = surface.get("affected_existing_entities").map(str_list).unwrap_or_default();
let mut check = |group: &str, candidates: Vec<String>, present: &dyn Fn(&str) -> bool| {
let mut check = |group: &'static str, candidates: &[&str], present: &dyn Fn(&str) -> bool| {
if candidates.is_empty() {
return;
}
groups_declared += 1;
let hits: Vec<String> = candidates.iter().filter(|c| present(c)).cloned().collect();
let hits: Vec<&str> = candidates.iter().filter(|c| present(c)).copied().collect();
if !hits.is_empty() {
groups_hit.push(group.to_string());
groups_hit.push(group);
evidence.insert(group.to_string(), json!(hits.iter().take(5).collect::<Vec<_>>()));
}
};
check(
"surface_kinds",
signals.get("surface_kinds").map(str_list).unwrap_or_default(),
&|c| surface_kind_present(c, surface),
);
check(
"path_contains",
signals.get("path_contains").map(str_list).unwrap_or_default(),
&|c| paths.iter().any(|p| path_matches(c, p)),
);
check(
"entities_match",
signals.get("entities_match").map(str_list).unwrap_or_default(),
&|c| entities.iter().any(|e| entity_matches(c, e)),
);
let surface_kinds = signals.get("surface_kinds").map(str_list).unwrap_or_default();
check("surface_kinds", &surface_kinds, &|c| surface_kind_present(c, surface));
let path_signals = signals.get("path_contains").map(str_list).unwrap_or_default();
check("path_contains", &path_signals, &|c| paths.iter().any(|p| path_matches(c, p)));
let entity_signals = signals.get("entities_match").map(str_list).unwrap_or_default();
check("entities_match", &entity_signals, &|c| entities.iter().any(|e| entity_matches(c, e)));
let objective_words: Vec<String> =
objective.replace('/', " ").split_whitespace().map(|w| w.to_lowercase()).collect();
check(
"objective_any",
signals
.get("objective_any")
.map(str_list)
.unwrap_or_default()
.into_iter()
.map(|w| w.to_lowercase())
.collect(),
&|c| objective_words.iter().any(|w| w == c),
);
let objective_signals_owned: Vec<String> =
signals.get("objective_any").map(str_list).unwrap_or_default().into_iter().map(str::to_lowercase).collect();
let objective_signals: Vec<&str> = objective_signals_owned.iter().map(String::as_str).collect();
check("objective_any", &objective_signals, &|c| objective_words.iter().any(|w| w == c));
if let Some(contracts) = signals.get("contracts_match").and_then(|v| v.as_array()) {
if !contracts.is_empty() {
groups_declared += 1;
let wanted: Vec<&str> = contracts.iter().filter_map(|c| c.as_str()).collect();
let mut hits: Vec<String> = Vec::new();
for doc in authorities.values() {
for e in &entities {
for r in relationships(doc, e) {
let rtype = r.get("type").and_then(|t| t.as_str()).unwrap_or("");
let to = r.get("to").and_then(|t| t.as_str()).unwrap_or("");
if REL_TYPES.contains(&rtype) && wanted.contains(&to) {
hits.push(format!("{e}->{to}"));
}
}
}
}
let hits: Vec<String> = authorities
.values()
.flat_map(|doc| entities.iter().map(move |&e| (doc, e)))
.flat_map(|(doc, e)| relationships(doc, e).into_iter().map(move |r| (e, r)))
.filter(|(_, r)| REL_TYPES.contains(&r.get("type").and_then(|t| t.as_str()).unwrap_or("")))
.map(|(e, r)| (e, r.get("to").and_then(|t| t.as_str()).unwrap_or("")))
.filter(|(_, to)| wanted.contains(to))
.map(|(e, to)| format!("{e}->{to}"))
.collect();
if !hits.is_empty() {
groups_hit.push("contracts_match".to_string());
groups_hit.push("contracts_match");
evidence.insert("contracts_match".into(), json!(hits.iter().take(5).collect::<Vec<_>>()));
}
}
@@ -434,24 +513,24 @@ pub fn evaluate_applicability(
if groups_declared == 0 {
return json!({
"pattern": pattern_id,
"verdict": UNKNOWN,
"verdict": ApplicabilityVerdict::Unknown.as_str(),
"reason": "no applicable signal groups declared",
"evidence": {},
});
}
let verdict = if groups_hit.len() as i64 == groups_declared {
APPLIES
let verdict = if groups_hit.len() == groups_declared {
ApplicabilityVerdict::Applies
} else if !groups_hit.is_empty() {
PARTIAL
ApplicabilityVerdict::Partial
} else {
DOES_NOT_APPLY
ApplicabilityVerdict::DoesNotApply
};
let mut hit_sorted = groups_hit.clone();
hit_sorted.sort();
hit_sorted.sort_unstable();
let detail = if hit_sorted.is_empty() { "none".to_string() } else { py_str_list(&hit_sorted) };
json!({
"pattern": pattern_id,
"verdict": verdict,
"verdict": verdict.as_str(),
"reason": format!("{}/{} signal groups produced evidence: {}", groups_hit.len(), groups_declared, detail),
"evidence": evidence,
})
@@ -465,15 +544,15 @@ pub fn obligation_when_hit(
need: &Map<String, Value>,
surface: &Map<String, Value>,
authorities: &Map<String, Value>,
) -> (String, String) {
let paths: Vec<String> = surface
) -> (ObligationVerdict, String) {
let paths: Vec<&str> = surface
.get("affected_paths")
.map(str_list)
.unwrap_or_default()
.into_iter()
.chain(surface.get("workflow_config_surfaces").map(str_list).unwrap_or_default())
.collect();
let entities: Vec<String> = surface
let entities: Vec<&str> = surface
.get("affected_existing_entities")
.map(str_list)
.unwrap_or_default()
@@ -481,66 +560,69 @@ pub fn obligation_when_hit(
.chain(surface.get("affected_ui_surfaces").map(str_list).unwrap_or_default())
.chain(surface.get("affected_regions").map(str_list).unwrap_or_default())
.collect();
let when = match obligation.get("when") {
None | Some(Value::Null) => {
return (OBL_UNKNOWN.to_string(), "obligation declares no applicability condition".to_string());
}
Some(w) => w,
};
if when.get("always").and_then(|v| v.as_bool()).unwrap_or(false) {
return (
OBL_REQUIRED.to_string(),
match when_shape(obligation) {
WhenShape::Unconditioned => (
ObligationVerdict::Unknown,
"obligation declares no applicability condition".to_string(),
),
WhenShape::Always => (
ObligationVerdict::Required,
"pattern-level obligation (always applies when the pattern applies)".to_string(),
);
}
if let Some(subs) = when.get("path_contains") {
let hits: Vec<String> =
str_list(subs).iter().flat_map(|sub| paths.iter().filter(|p| path_matches(sub, p)).cloned()).collect();
if !hits.is_empty() {
return (OBL_APPLICABLE.to_string(), format!("affected path(s): {}", py_str_list(&hits[..3.min(hits.len())])));
),
WhenShape::PathContains(subs) => {
let hits: Vec<&str> = str_list(subs)
.iter()
.flat_map(|sub| paths.iter().filter(|p| path_matches(sub, p)).copied())
.collect();
if !hits.is_empty() {
return (ObligationVerdict::Applicable, format!("affected path(s): {}", py_str_list(&hits[..3.min(hits.len())])));
}
(ObligationVerdict::NotApplicable, format!("no affected path matches {}", py_repr(subs)))
}
return (OBL_NOT_APPLICABLE.to_string(), format!("no affected path matches {}", py_repr(subs)));
}
if let Some(subs) = when.get("entities_match") {
let hits: Vec<String> = str_list(subs)
.iter()
.flat_map(|sub| entities.iter().filter(|e| entity_matches(sub, e)).cloned())
.collect();
if !hits.is_empty() {
return (
OBL_APPLICABLE.to_string(),
format!("affected entity/entities: {}", py_str_list(&hits[..3.min(hits.len())])),
);
WhenShape::EntitiesMatch(subs) => {
let hits: Vec<&str> = str_list(subs)
.iter()
.flat_map(|sub| entities.iter().filter(|e| entity_matches(sub, e)).copied())
.collect();
if !hits.is_empty() {
return (
ObligationVerdict::Applicable,
format!("affected entity/entities: {}", py_str_list(&hits[..3.min(hits.len())])),
);
}
(ObligationVerdict::NotApplicable, format!("no affected entity matches {}", py_repr(subs)))
}
return (OBL_NOT_APPLICABLE.to_string(), format!("no affected entity matches {}", py_repr(subs)));
}
if let Some(wanted) = when.get("contracts_touched") {
let mut touched: Vec<String> = need
.get("constraints")
.and_then(|c| c.get("contracts"))
.map(str_list)
.unwrap_or_default();
for doc in authorities.values() {
for e in &entities {
for r in relationships(doc, e) {
let rtype = r.get("type").and_then(|t| t.as_str()).unwrap_or("");
let to = r.get("to").and_then(|t| t.as_str()).unwrap_or("").to_string();
if REL_TYPES.contains(&rtype) && !touched.contains(&to) {
touched.push(to);
WhenShape::ContractsTouched(wanted) => {
let mut touched: Vec<String> = need
.get("constraints")
.and_then(|c| c.get("contracts"))
.map(str_list_owned)
.unwrap_or_default();
for doc in authorities.values() {
for e in &entities {
for r in relationships(doc, e) {
let rtype = r.get("type").and_then(|t| t.as_str()).unwrap_or("");
let to = r.get("to").and_then(|t| t.as_str()).unwrap_or("");
if REL_TYPES.contains(&rtype) && !touched.iter().any(|t| t == to) {
touched.push(to.to_string());
}
}
}
}
let hits: Vec<&str> =
str_list(wanted).into_iter().filter(|c| touched.iter().any(|t| t == c)).collect();
if !hits.is_empty() {
return (ObligationVerdict::Applicable, format!("contract(s) touched: {}", py_str_list(&hits)));
}
(ObligationVerdict::NotApplicable, format!("touched contracts do not include {}", py_repr(wanted)))
}
let hits: Vec<String> =
str_list(wanted).into_iter().filter(|c| touched.contains(c)).collect();
if !hits.is_empty() {
return (OBL_APPLICABLE.to_string(), format!("contract(s) touched: {}", py_str_list(&hits)));
WhenShape::Unrecognized(keys) => {
let mut keys = keys;
keys.sort_unstable();
(ObligationVerdict::Unknown, format!("unrecognized when-condition shape: {}", py_str_list(&keys)))
}
return (OBL_NOT_APPLICABLE.to_string(), format!("touched contracts do not include {}", py_repr(wanted)));
}
let mut keys: Vec<String> = when.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default();
keys.sort();
(OBL_UNKNOWN.to_string(), format!("unrecognized when-condition shape: {}", py_str_list(&keys)))
}
/// Derive this pattern's obligations FILTERED by project evidence (§7).
@@ -559,7 +641,8 @@ pub fn derive_pattern_obligations(
let mut unresolved = Vec::new();
if let Some(obs) = pattern.get("obligations").and_then(|v| v.as_array()) {
for o in obs {
let o_obj = o.as_object().cloned().unwrap_or_default();
let empty = Map::new();
let o_obj: &Map<String, Value> = o.as_object().unwrap_or(&empty);
let mut entry = Map::new();
entry.insert("id".into(), o.get("id").cloned().unwrap_or(Value::Null));
entry.insert("obligation".into(), o.get("obligation").cloned().unwrap_or(Value::Null));
@@ -590,15 +673,13 @@ pub fn derive_pattern_obligations(
rejected.push(Value::Object(entry));
continue;
}
let (verdict, reason) = obligation_when_hit(&o_obj, need, surface, authorities);
entry.insert("verdict".into(), Value::String(verdict.clone()));
let (verdict, reason) = obligation_when_hit(o_obj, need, surface, authorities);
entry.insert("verdict".into(), Value::String(verdict.as_str().to_string()));
entry.insert("reason".into(), Value::String(reason));
if verdict == OBL_REQUIRED || verdict == OBL_APPLICABLE {
accepted.push(Value::Object(entry));
} else if verdict == OBL_NOT_APPLICABLE {
rejected.push(Value::Object(entry));
} else {
unresolved.push(Value::Object(entry));
match verdict {
ObligationVerdict::Required | ObligationVerdict::Applicable => accepted.push(Value::Object(entry)),
ObligationVerdict::NotApplicable => rejected.push(Value::Object(entry)),
ObligationVerdict::Unknown => unresolved.push(Value::Object(entry)),
}
}
}
@@ -655,7 +736,7 @@ pub fn combine_pattern_obligations(derivations: &[Value]) -> Value {
}
}
}
order.sort();
order.sort_unstable();
let obligations: Vec<Value> = order.into_iter().filter_map(|k| merged.remove(&k)).collect();
json!({"obligations": obligations, "conflicts": conflicts})
}