halo-agent 0.1.0

Halo Agent and Workflow execution kernel
use futures_executor::block_on;
use halo::agent::Cancellation;
use halo::agent::document::{
    self, Chunker as _, Document, Loader as _, Parser as _, Transformer as _,
};
use serde_json::{Value, json};

fn document(content: &str) -> Document {
    Document {
        content: content.into(),
        ..Default::default()
    }
}

fn split(method: &str, options: Value, documents: Vec<Document>) -> Vec<Document> {
    let chunker = document::chunker::new(method, options).unwrap();
    let mut id = 1000;
    block_on(chunker.chunk(documents, &Cancellation::new(), &mut || {
        id += 1;
        id
    }))
    .unwrap()
}

fn texts(chunks: &[Document]) -> Vec<&str> {
    chunks.iter().map(|chunk| chunk.content.as_str()).collect()
}

#[test]
fn fixed_counts_unicode_scalars_not_bytes_or_tokens() {
    let chunks = split(
        "fixed",
        json!({"max_chars":2}),
        vec![document("北京🙂a\r\n")],
    );
    assert_eq!(texts(&chunks), ["北京", "🙂a", "\r\n"]);
    assert_eq!(
        chunks.iter().map(|c| c.id.unwrap()).collect::<Vec<_>>(),
        [1001, 1002, 1003]
    );
}

#[test]
fn sliding_preserves_overlap_and_does_not_create_a_redundant_tail() {
    let chunks = split(
        "sliding",
        json!({"max_chars":4,"overlap":2}),
        vec![document("abcdef")],
    );
    assert_eq!(texts(&chunks), ["abcd", "cdef"]);
    let chunks = split(
        "sliding",
        json!({"max_chars":4,"overlap":2}),
        vec![document("abcdefg")],
    );
    assert_eq!(texts(&chunks), ["abcd", "cdef", "efg"]);
}

#[test]
fn sliding_with_zero_overlap_equals_fixed() {
    let inputs = vec![document("abc🙂def")];
    assert_eq!(
        split("fixed", json!({"max_chars":3}), inputs.clone()),
        split("sliding", json!({"max_chars":3}), inputs)
    );
}

#[test]
fn delimiter_preserves_crlf_and_merges_small_units() {
    let input = "ab\r\ncd\r\ne";
    let chunks = split(
        "delimiter",
        json!({"max_chars":8,"delimiter":"\r\n"}),
        vec![document(input)],
    );
    assert_eq!(texts(&chunks), ["ab\r\ncd\r\n", "e"]);
    assert_eq!(texts(&chunks).concat(), input);
}

#[test]
fn long_delimited_units_are_split_without_losing_repeated_separators() {
    let input = "甲乙丙丁戊。。,己庚辛壬。";
    let chunks = split(
        "delimiter",
        json!({"max_chars":3,"delimiter":"。"}),
        vec![document(input)],
    );
    assert_eq!(texts(&chunks).concat(), input);
    assert!(
        chunks
            .iter()
            .all(|chunk| chunk.content.chars().count() <= 3)
    );
}

#[test]
fn fixed_and_delimiter_round_trip_small_unicode_corpora_and_sizes() {
    for input in [
        "",
        "abc",
        "甲。乙。丙",
        "🙂e\u{301}🇨🇳",
        "\r\n\r\n",
        "  \t  ",
        "。。。",
    ] {
        for size in 1..=12 {
            for method in ["fixed", "delimiter"] {
                let mut options = json!({"max_chars":size});
                if method == "delimiter" {
                    options["delimiter"] = json!("。");
                }
                let chunks = split(method, options, vec![document(input)]);
                assert_eq!(texts(&chunks).concat(), input);
                assert!(
                    chunks
                        .iter()
                        .all(|chunk| (1..=size).contains(&chunk.content.chars().count()))
                );
            }
        }
    }
}

#[test]
fn configuration_errors_are_not_silently_defaulted() {
    for (method, options) in [
        ("fixed", json!({})),
        ("fixed", json!({"max_chars":0})),
        ("fixed", json!({"max_chars":-1})),
        ("fixed", json!({"max_chars":1.5})),
        ("fixed", json!({"max_chars":2,"max_tokens":20})),
        ("fixed", json!({"max_chars":2,"overlap":1})),
        ("fixed", json!({"max_chars":2,"delimiter":"."})),
        ("sliding", json!({"max_chars":2,"overlap":2})),
        ("sliding", json!({"max_chars":2,"overlap":3})),
        ("sliding", json!({"max_chars":2,"overlap":-1})),
        ("sliding", json!({"max_chars":2,"delimiter":"."})),
        ("delimiter", json!({"max_chars":2})),
        ("delimiter", json!({"max_chars":2,"delimiter":""})),
        ("delimiter", json!({"max_chars":2,"delimiter":false})),
        (
            "delimiter",
            json!({"max_chars":2,"delimiter":".","overlap":0}),
        ),
        ("fixed", Value::Null),
        ("fixed", json!({"max_chars":2,"overlap":null})),
        ("sliding", json!({"max_chars":2,"overlap":null})),
        ("delimiter", json!({"max_chars":2,"delimiter":null})),
    ] {
        assert_eq!(
            document::chunker::new(method, options).err().unwrap().code,
            "INVALID_ARGUMENTS"
        );
    }
}

#[test]
fn semantic_and_unknown_methods_do_not_pretend_to_be_fixed_splitters() {
    for method in ["semantic", "laws", "qa", "FIXED", ""] {
        assert_eq!(
            document::chunker::new(method, json!({"max_chars":2}))
                .err()
                .unwrap()
                .code,
            "UNSUPPORTED"
        );
    }
}

#[test]
fn different_documents_remain_separate_and_keep_application_provenance() {
    let mut a = document("a");
    a.doc_id = Some(10);
    a.id = Some(11);
    a.metadata.insert("source".into(), json!({"uri":"a.txt"}));
    let mut b = document("b");
    b.doc_id = Some(20);
    let chunks = split(
        "delimiter",
        json!({"max_chars":100,"delimiter":"\n"}),
        vec![a, b],
    );
    assert_eq!(texts(&chunks), ["a", "b"]);
    assert_eq!(chunks[0].doc_id, Some(10));
    assert_eq!(chunks[1].doc_id, Some(20));
    assert_ne!(chunks[0].id, Some(11));
    assert_eq!(chunks[0].metadata["source"], json!({"uri":"a.txt"}));
}

#[test]
fn recutting_allocates_new_ids_instead_of_hashing_or_reusing_input_ids() {
    let chunker = document::chunker::new("fixed", json!({"max_chars":100})).unwrap();
    let mut id = 100;
    let mut next_id = || {
        id += 1;
        id
    };
    let input = vec![document("unchanged")];
    let first = block_on(chunker.chunk(input.clone(), &Cancellation::new(), &mut next_id)).unwrap();
    let second =
        block_on(chunker.chunk(first.clone(), &Cancellation::new(), &mut next_id)).unwrap();
    assert_eq!(texts(&first), texts(&second));
    assert_ne!(first[0].id, second[0].id);
}

#[test]
fn duplicate_generated_ids_return_error_without_partial_collection_or_retry() {
    let chunker = document::chunker::new("fixed", json!({"max_chars":1})).unwrap();
    let mut calls = 0;
    let result = block_on(
        chunker.chunk(vec![document("abc")], &Cancellation::new(), &mut || {
            calls += 1;
            100
        }),
    );
    assert_eq!(result.unwrap_err().code, "INVALID_ARGUMENTS");
    assert_eq!(calls, 2);
}

#[test]
fn generated_ids_cannot_reuse_an_existing_input_identity() {
    let chunker = document::chunker::new("fixed", json!({"max_chars":2})).unwrap();
    let mut input = document("abc");
    input.id = Some(100);
    let error =
        block_on(chunker.chunk(vec![input], &Cancellation::new(), &mut || 100)).unwrap_err();
    assert_eq!(error.code, "INVALID_ARGUMENTS");
}

#[test]
fn empty_input_does_not_allocate_ids_but_whitespace_is_not_silently_trimmed() {
    let chunker = document::chunker::new("fixed", json!({"max_chars":2})).unwrap();
    assert!(
        block_on(
            chunker.chunk(vec![document("")], &Cancellation::new(), &mut || panic!(
                "no ID needed"
            ))
        )
        .unwrap()
        .is_empty()
    );
    let chunks = split("fixed", json!({"max_chars":2}), vec![document("  \t ")]);
    assert_eq!(texts(&chunks).concat(), "  \t ");
}

#[test]
fn cancelled_calls_do_not_allocate_or_expose_chunks() {
    let chunker = document::chunker::new("fixed", json!({"max_chars":2})).unwrap();
    let cancellation = Cancellation::new();
    cancellation.cancel();
    let error = block_on(
        chunker.chunk(vec![document("abc")], &cancellation, &mut || {
            panic!("cancelled")
        }),
    )
    .unwrap_err();
    assert_eq!(error.code, "CANCELLED");
}

#[test]
fn cancellation_during_id_assignment_also_prevents_successful_delivery() {
    let chunker = document::chunker::new("fixed", json!({"max_chars":2})).unwrap();
    let cancellation = Cancellation::new();
    let error = block_on(chunker.chunk(vec![document("ab")], &cancellation, &mut || {
        cancellation.cancel();
        100
    }))
    .unwrap_err();
    assert_eq!(error.code, "CANCELLED");
}

#[test]
fn single_paragraph_character_positions_are_adjusted_without_resetting_the_origin() {
    let mut input = document("甲🙂乙丙");
    input.metadata = json!({"source":{"uri":"x.txt"},"method":"text","vector":[0.1],"location":{
        "page":{"from":3,"to":3},"paragraph":{"from":2,"to":2},"char":{"from":11,"to":14},
        "x":{"from":0.1,"to":0.9},"y":{"from":0.2,"to":0.8}
    }})
    .as_object()
    .unwrap()
    .clone();
    let chunks = split("sliding", json!({"max_chars":3,"overlap":1}), vec![input]);
    assert_eq!(
        chunks[0].metadata["location"]["char"],
        json!({"from":11,"to":13})
    );
    assert_eq!(
        chunks[1].metadata["location"]["char"],
        json!({"from":13,"to":14})
    );
    for chunk in chunks {
        assert_eq!(chunk.metadata["location"]["page"], json!({"from":3,"to":3}));
        assert_eq!(chunk.metadata["method"], "text");
        assert!(chunk.metadata.get("vector").is_none());
        assert!(chunk.metadata["location"].get("x").is_none());
        assert!(chunk.metadata.get("source").is_some());
    }
}

#[test]
fn whole_text_chunk_keeps_existing_vector_and_location() {
    let mut input = document("ab");
    input.metadata = json!({"vector":[0.1],"location":{"page":{"from":1,"to":2}}})
        .as_object()
        .unwrap()
        .clone();
    let chunks = split("fixed", json!({"max_chars":2}), vec![input.clone()]);
    assert_eq!(chunks[0].metadata, input.metadata);
}

#[test]
fn sliced_multi_page_ranges_are_not_interpolated_or_copied_to_every_chunk() {
    let mut input = document("abcd");
    input.metadata = json!({"location":{"page":{"from":1,"to":2},"paragraph":{"from":1,"to":3},"char":{"from":1,"to":4}},"source":{"uri":"x.pdf"}}).as_object().unwrap().clone();
    let chunks = split("fixed", json!({"max_chars":2}), vec![input]);
    assert!(
        chunks
            .iter()
            .all(|chunk| !chunk.metadata.contains_key("location")
                && chunk.metadata.contains_key("source"))
    );
}

#[test]
fn partial_text_can_keep_known_page_without_inventing_paragraphs() {
    let mut input = document("abcd");
    input.metadata = json!({"location":{"page":{"from":3,"to":3},"paragraph":{"from":1,"to":2},"char":{"from":1,"to":4}}}).as_object().unwrap().clone();
    let chunks = split("fixed", json!({"max_chars":2}), vec![input]);
    assert!(
        chunks
            .iter()
            .all(|c| c.metadata["location"] == json!({"page":{"from":3,"to":3}}))
    );
}

#[test]
fn mismatched_or_invalid_character_ranges_are_not_claimed_as_valid() {
    for location in [
        json!({"paragraph":{"from":1,"to":1},"char":{"from":1,"to":10}}),
        json!({"paragraph":{"from":0,"to":0}}),
    ] {
        let mut input = document("abcd");
        input.metadata.insert("location".into(), location);
        let chunks = split("fixed", json!({"max_chars":2}), vec![input]);
        assert!(chunks.iter().all(|c| {
            c.metadata
                .get("location")
                .is_none_or(|l| l.get("char").is_none())
        }));
    }
}

#[test]
fn actual_local_file_pipeline_can_continue_through_chunking_without_runtime() {
    let cancellation = Cancellation::new();
    let source = document::Source {
        uri: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/qa.csv").into(),
    };
    let bytes = block_on(document::LocalLoader.load(&source, &cancellation)).unwrap();
    let parser = document::parser::new("qa", json!({"format":"csv","engine":"csv"})).unwrap();
    let mut documents = block_on(parser.parse(&bytes, &cancellation)).unwrap();
    for document in &mut documents {
        document.doc_id = Some(900);
    }
    let documents = block_on(
        document::transformer::new()
            .remove_empty()
            .transform(documents, &cancellation),
    )
    .unwrap();
    let original: String = documents.iter().map(|d| d.content.as_str()).collect();
    let chunks = split(
        "delimiter",
        json!({"max_chars":12,"delimiter":"\n"}),
        documents,
    );
    assert_eq!(texts(&chunks).concat(), original);
    assert!(
        chunks
            .iter()
            .all(|c| c.doc_id == Some(900) && c.id.is_some())
    );
}