use std::collections::HashMap;
use panproto_gat::Name;
use panproto_schema::Schema;
use super::{Anchor, StrategyTag, kinds_compatible};
use crate::coerce::{SortLensWitness, WitnessLibrary};
#[must_use]
pub fn coerce_anchors(src: &Schema, tgt: &Schema, library: &WitnessLibrary) -> Vec<CoerceAnchor> {
if library.is_empty() {
return Vec::new();
}
let src_value_kinds = schema_value_kinds(src);
let tgt_value_kinds = schema_value_kinds(tgt);
let mut src_ids: Vec<&Name> = src.vertices.keys().collect();
src_ids.sort_by_key(|n| n.as_str());
let mut tgt_ids: Vec<&Name> = tgt.vertices.keys().collect();
tgt_ids.sort_by_key(|n| n.as_str());
let mut out = Vec::new();
for src_id in src_ids {
let Some(src_kind) = src_value_kinds.get(src_id).copied() else {
continue;
};
let mut best: Option<(Name, &SortLensWitness, f64)> = None;
for tgt_id in &tgt_ids {
if kinds_compatible(src, src_id, tgt, tgt_id) {
continue; }
let Some(tgt_kind) = tgt_value_kinds.get(*tgt_id).copied() else {
continue;
};
for witness in library.lookup(src_kind, tgt_kind) {
let confidence = class_confidence(witness.class);
let swap = best.as_ref().is_none_or(|(_, prev_w, prev_c)| {
match confidence.total_cmp(prev_c) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => witness.name < prev_w.name,
}
});
if swap {
best = Some(((*tgt_id).clone(), witness, confidence));
}
}
}
if let Some((tgt_id, witness, confidence)) = best {
out.push(CoerceAnchor {
anchor: Anchor {
src: src_id.clone(),
tgt: tgt_id.clone(),
confidence,
strategy: StrategyTag::Coerce,
explanation: format!(
"sort-coercion {}: {} ↔ {} ({:?})",
witness.description,
src_id.as_str(),
tgt_id.as_str(),
witness.class
),
},
witness_name: witness.name.clone(),
witness_class: witness.class,
});
}
}
out
}
#[derive(Clone, Debug)]
pub struct CoerceAnchor {
pub anchor: Anchor,
pub witness_name: String,
pub witness_class: panproto_gat::CoercionClass,
}
const fn class_confidence(class: panproto_gat::CoercionClass) -> f64 {
match class {
panproto_gat::CoercionClass::Iso => 0.8,
panproto_gat::CoercionClass::Retraction => 0.55,
panproto_gat::CoercionClass::Projection => 0.35,
_ => 0.2,
}
}
fn schema_value_kinds(schema: &Schema) -> HashMap<Name, panproto_gat::ValueKind> {
use panproto_gat::ValueKind;
let mut out = HashMap::new();
for (id, vertex) in &schema.vertices {
let vk = match vertex.kind.as_str() {
"bool" | "boolean" => Some(ValueKind::Bool),
"int" | "integer" => Some(ValueKind::Int),
"float" | "number" => Some(ValueKind::Float),
"str" | "string" => Some(ValueKind::Str),
"bytes" => Some(ValueKind::Bytes),
"token" => Some(ValueKind::Token),
"null" => Some(ValueKind::Null),
_ => None,
};
if let Some(vk) = vk {
out.insert(id.clone(), vk);
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::coerce::default_witness_library;
use panproto_schema::{Protocol, SchemaBuilder};
fn test_protocol() -> Protocol {
Protocol {
name: "test".into(),
schema_theory: "ThTest".into(),
instance_theory: "ThWType".into(),
edge_rules: vec![],
obj_kinds: vec![
"record".into(),
"string".into(),
"integer".into(),
"boolean".into(),
"float".into(),
],
constraint_sorts: vec![],
..Protocol::default()
}
}
fn build(verts: &[(&str, &str)], edges: &[(&str, &str, &str, &str)]) -> Schema {
let proto = test_protocol();
let mut b = SchemaBuilder::new(&proto);
for (id, k) in verts {
b = b.vertex(id, k, None::<&str>).unwrap();
}
for (s, t, k, n) in edges {
b = b.edge(s, t, k, Some(*n)).unwrap();
}
b.build().unwrap()
}
#[test]
fn proposes_int_to_str_coercion() {
let src = build(
&[("r", "record"), ("r.n", "integer")],
&[("r", "r.n", "prop", "n")],
);
let tgt = build(
&[("r", "record"), ("r.n", "string")],
&[("r", "r.n", "prop", "n")],
);
let lib = default_witness_library();
let anchors = coerce_anchors(&src, &tgt, &lib);
assert!(
anchors.iter().any(|a| a.anchor.src.as_str() == "r.n"
&& a.anchor.tgt.as_str() == "r.n"
&& a.witness_name == "int_to_str"),
"expected int_to_str coerce anchor on r.n; got {anchors:?}"
);
}
#[test]
fn skips_identity_kind_matches() {
let src = build(
&[("r", "record"), ("r.n", "integer")],
&[("r", "r.n", "prop", "n")],
);
let tgt = src.clone();
let lib = default_witness_library();
let anchors = coerce_anchors(&src, &tgt, &lib);
assert!(
anchors.iter().all(|a| a.anchor.src.as_str() != "r.n"),
"should not emit coerce anchors on identity-kind pairs"
);
}
#[test]
fn empty_library_emits_nothing() {
let src = build(
&[("r", "record"), ("r.n", "integer")],
&[("r", "r.n", "prop", "n")],
);
let tgt = build(
&[("r", "record"), ("r.n", "string")],
&[("r", "r.n", "prop", "n")],
);
let lib = WitnessLibrary::new();
let anchors = coerce_anchors(&src, &tgt, &lib);
assert!(anchors.is_empty());
}
#[test]
fn prefers_higher_class_when_tied() {
let src = build(
&[("r", "record"), ("r.n", "integer")],
&[("r", "r.n", "prop", "n")],
);
let tgt = build(
&[
("r", "record"),
("r.n_str", "string"),
("r.n_float", "float"),
],
&[
("r", "r.n_str", "prop", "n_str"),
("r", "r.n_float", "prop", "n_float"),
],
);
let lib = default_witness_library();
let anchors = coerce_anchors(&src, &tgt, &lib);
let picked = anchors
.iter()
.find(|a| a.anchor.src.as_str() == "r.n")
.expect("should emit a coerce anchor for r.n");
assert!(
picked.witness_name == "int_to_str" || picked.witness_name == "int_to_float",
"expected a library witness; got {}",
picked.witness_name
);
assert_eq!(
picked.witness_class,
panproto_gat::CoercionClass::Retraction
);
}
#[test]
fn iso_beats_retraction_when_both_available() {
let mut lib = WitnessLibrary::new();
let mut iso = crate::coerce::witness::int_to_str_witness();
iso.name = "int_to_str_iso".to_owned();
iso.class = panproto_gat::CoercionClass::Iso;
lib.register(iso);
lib.register(crate::coerce::witness::int_to_str_witness());
let src = build(
&[("r", "record"), ("r.n", "integer")],
&[("r", "r.n", "prop", "n")],
);
let tgt = build(
&[("r", "record"), ("r.s", "string")],
&[("r", "r.s", "prop", "s")],
);
let anchors = coerce_anchors(&src, &tgt, &lib);
let picked = anchors
.iter()
.find(|a| a.anchor.src.as_str() == "r.n")
.expect("should emit a coerce anchor");
assert_eq!(picked.witness_name, "int_to_str_iso");
}
#[test]
fn coerce_anchors_tie_breaks_on_witness_name() {
let mut lib = WitnessLibrary::new();
let mut alpha = crate::coerce::witness::int_to_str_witness();
alpha.name = "aaa_int_to_str".to_owned();
let mut omega = crate::coerce::witness::int_to_str_witness();
omega.name = "zzz_int_to_str".to_owned();
lib.register(omega);
lib.register(alpha);
let src = build(
&[("r", "record"), ("r.n", "integer")],
&[("r", "r.n", "prop", "n")],
);
let tgt = build(
&[("r", "record"), ("r.s", "string")],
&[("r", "r.s", "prop", "s")],
);
let anchors = coerce_anchors(&src, &tgt, &lib);
let picked = anchors
.iter()
.find(|a| a.anchor.src.as_str() == "r.n")
.expect("should emit a coerce anchor");
assert_eq!(
picked.witness_name, "aaa_int_to_str",
"tie-break must pick alphabetically earliest witness name"
);
}
#[test]
fn custom_kinds_do_not_participate_in_coerce_anchors() {
let src = build(
&[("r", "record"), ("r.rec", "record")],
&[("r", "r.rec", "prop", "rec")],
);
let tgt = build(
&[("r", "record"), ("r.rec", "string")],
&[("r", "r.rec", "prop", "rec")],
);
let lib = default_witness_library();
let anchors = coerce_anchors(&src, &tgt, &lib);
assert!(
anchors.iter().all(|a| a.anchor.src.as_str() != "r.rec"),
"non-primitive source kinds must not seed coerce anchors; got {anchors:?}"
);
}
#[test]
fn class_confidence_is_monotone_across_known_variants() {
use panproto_gat::CoercionClass;
assert!(class_confidence(CoercionClass::Iso) > class_confidence(CoercionClass::Retraction));
assert!(
class_confidence(CoercionClass::Retraction)
> class_confidence(CoercionClass::Projection)
);
assert!(
(class_confidence(CoercionClass::Opaque) - 0.2).abs() < 1e-9,
"Opaque should hit the conservative floor (0.2)"
);
assert!((class_confidence(CoercionClass::Iso) - 0.8).abs() < 1e-9);
assert!((class_confidence(CoercionClass::Retraction) - 0.55).abs() < 1e-9);
assert!((class_confidence(CoercionClass::Projection) - 0.35).abs() < 1e-9);
}
}