face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! ยง5.4 algorithmic string clustering.
//!
//! The implementation is deterministic and local: records are assigned
//! greedily to the first existing cluster whose representative text is
//! similar under the selected algorithm. No neural or embedding model is
//! involved.

use serde_json::{Value, json};

use crate::cluster_tree::util::score_range;
use crate::cluster_tree::{build_tree_impl, extend_id};
use crate::{
    AxisPlan, Cluster, ClusterId, Diagnostics, FaceError, Record, SimilarAlgorithm, SkipReason,
    SkipReport, Strategy,
};

pub(super) fn cluster<D: Diagnostics + ?Sized>(
    plan: &AxisPlan,
    items: Vec<Record>,
    parent: &ClusterId,
    diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
    let Strategy::Similar { algorithm } = &plan.axis.strategy else {
        unreachable!("similar clusterer is only called for Strategy::Similar")
    };
    let algorithm = *algorithm;

    let mut groups: Vec<SimilarGroup> = Vec::new();
    for (index, record) in items.into_iter().enumerate() {
        let Ok(resolved) = crate::path::resolve(&record.raw, &plan.axis.field) else {
            continue;
        };
        let Some(text) = resolved.as_str() else {
            diag.record_skip(SkipReport {
                record_index: index,
                reason: SkipReason::WrongType {
                    field: plan.axis.field.clone(),
                    kind: kind_name(resolved).to_string(),
                },
            });
            continue;
        };

        let text = text.to_string();
        if let Some(group) = groups
            .iter_mut()
            .find(|group| algorithm.matches(&text, &group.representative))
        {
            group.records.push(record);
        } else {
            groups.push(SimilarGroup {
                representative: text,
                records: vec![record],
            });
        }
    }

    groups.sort_by(|a, b| {
        b.records
            .len()
            .cmp(&a.records.len())
            .then_with(|| a.representative.cmp(&b.representative))
    });

    let mut out = Vec::with_capacity(groups.len());
    for group in groups {
        out.push(build_cluster(plan, parent, algorithm, group, diag)?);
    }
    Ok(out)
}

struct SimilarGroup {
    representative: String,
    records: Vec<Record>,
}

fn build_cluster<D: Diagnostics + ?Sized>(
    plan: &AxisPlan,
    parent: &ClusterId,
    algorithm: SimilarAlgorithm,
    group: SimilarGroup,
    diag: &mut D,
) -> Result<Cluster, FaceError> {
    let label = label_for(&group.representative);
    let id = extend_id(parent, &plan.axis.field, &label);
    let total = group.records.len() as u64;
    let (score_min, score_max) = score_range(&group.records);

    let children = if plan.within.is_empty() {
        Vec::new()
    } else {
        let mut acc = Vec::new();
        for child_plan in &plan.within {
            let child_clusters = build_tree_impl(child_plan, group.records.clone(), &id, diag)?;
            acc.extend(child_clusters);
        }
        acc
    };

    Ok(Cluster {
        id,
        label,
        axis: plan.axis.field.clone(),
        value: json!({
            "algorithm": algorithm.name(),
            "representative": group.representative,
        }),
        total,
        score_min,
        score_max,
        clusters: children,
    })
}

fn label_for(text: &str) -> String {
    const MAX_CHARS: usize = 64;
    let trimmed = text.trim();
    let mut label = trimmed.chars().take(MAX_CHARS).collect::<String>();
    if trimmed.chars().count() > MAX_CHARS {
        label.push_str("...");
    }
    if label.is_empty() {
        "(empty)".to_string()
    } else {
        label
    }
}

fn kind_name(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "bool",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}