From c391936eeb413738bd1a480d84f2cd5bbaa2a674 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 19 Oct 2026 14:48:46 -0400 Subject: [PATCH 1/4] feat: add Pangram 4.1 short-text interpretation guidance --- .tegami/pangram-4-1-guidance.md | 9 +++ docs-app/content/docs/index.mdx | 8 +++ docs-app/public/llms-full.txt | 8 +++ docs-app/public/markdown/index.md | 8 +++ docs/contracts.md | 18 ++++++ docs/pangram-4-1-investigation.md | 39 ++++++++++++ src/domain.rs | 2 + src/domain/detection_guidance.rs | 59 +++++++++++++++++++ src/output/projection.rs | 3 + src/tui/result-lines.rs | 8 ++- tests/projection-contract.rs | 11 ++++ tests/support/golden/markdown_success.md | 1 + tests/support/golden/pretty_success.txt | 1 + tests/support/golden/pretty_success_color.txt | 1 + .../__snapshots__/tui-acceptance.test.ts.snap | 32 +++++----- tools/tui-acceptance/tui-acceptance.test.ts | 2 +- 16 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 .tegami/pangram-4-1-guidance.md create mode 100644 docs/pangram-4-1-investigation.md create mode 100644 src/domain/detection_guidance.rs diff --git a/.tegami/pangram-4-1-guidance.md b/.tegami/pangram-4-1-guidance.md new file mode 100644 index 0000000..8b043d6 --- /dev/null +++ b/.tegami/pangram-4-1-guidance.md @@ -0,0 +1,9 @@ +--- +packages: + "cargo:microck-pangram-cli": patch + "npm:@microck/pangram-cli": patch +--- + +## Changed + +AI detection results in pretty, Markdown, and TUI views now caution that scans below 150 words are less certain, following Pangram 4.1's published interpretation guidance. Scores and machine-readable schemas are unchanged; undocumented dashboard uncertainty ranges are not fabricated. diff --git a/docs-app/content/docs/index.mdx b/docs-app/content/docs/index.mdx index f9b2777..b42d482 100644 --- a/docs-app/content/docs/index.mdx +++ b/docs-app/content/docs/index.mdx @@ -14,3 +14,11 @@ It is an unofficial MIT-licensed client. Content sent for analysis goes to Pangram. Local history is off by default. Every billable MCP submission requires a caller-supplied cost ceiling. + +## Pangram 4.1 interpretation guidance + +Human-readable AI results warn when the returned segments total fewer than 150 +words. This reflects the new short-scan guidance; it does not change the model's +score or imply that longer scans are proof of authorship. The announced dashboard +AI-percentage range is not yet part of the documented public API contract, so the +CLI does not manufacture one from segment confidence. diff --git a/docs-app/public/llms-full.txt b/docs-app/public/llms-full.txt index 29b7cab..81b6df8 100644 --- a/docs-app/public/llms-full.txt +++ b/docs-app/public/llms-full.txt @@ -422,6 +422,14 @@ It is an unofficial MIT-licensed client. Content sent for analysis goes to Pangram. Local history is off by default. Every billable MCP submission requires a caller-supplied cost ceiling. +## Pangram 4.1 interpretation guidance + +Human-readable AI results warn when the returned segments total fewer than 150 +words. This reflects the new short-scan guidance; it does not change the model's +score or imply that longer scans are proof of authorship. The announced dashboard +AI-percentage range is not yet part of the documented public API contract, so the +CLI does not manufacture one from segment confidence. + # Authentication reference Source: https://pangram.micr.dev/docs/reference/authentication diff --git a/docs-app/public/markdown/index.md b/docs-app/public/markdown/index.md index eced73e..bb17e12 100644 --- a/docs-app/public/markdown/index.md +++ b/docs-app/public/markdown/index.md @@ -11,3 +11,11 @@ It is an unofficial MIT-licensed client. Content sent for analysis goes to Pangram. Local history is off by default. Every billable MCP submission requires a caller-supplied cost ceiling. + +## Pangram 4.1 interpretation guidance + +Human-readable AI results warn when the returned segments total fewer than 150 +words. This reflects the new short-scan guidance; it does not change the model's +score or imply that longer scans are proof of authorship. The announced dashboard +AI-percentage range is not yet part of the documented public API contract, so the +CLI does not manufacture one from segment confidence. diff --git a/docs/contracts.md b/docs/contracts.md index f9a1c2d..30d7cc2 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -2475,3 +2475,21 @@ non-full `skills get` command. It also owns the exact `skills list` Markdown and the two newline-terminated locator projections. Drift tests compare each compiled command's stdout byte-for-byte with its owning generated or embedded source. + +## Pangram 4.1 interpretation guidance (2026-10-19) + +Human-readable AI result projections (pretty, Markdown, and TUI Analyze/History) +show a short-text caution when the returned complete segment sequence has a +summed word count below 150. An absent segment sequence or overflowing count is +unknown and does not produce a short-text claim. The caution applies regardless +of classification or segment confidence; a high-confidence segment does not +cancel the document-length limitation. It does not alter scores, classifications, +exit codes, billing, saved canonical data, or JSON/JSONL/TOON schemas. + +The 4.1 announcement establishes improved calibration, a dashboard estimated +AI-percentage range, a short-scan notice, and reported false-positive improvements. +It does not establish new public API request selectors, response-version values, +or range-field names. Until documented or authorized live conformance establishes +those details, the client retains its documented request/response contract and +does not derive a numerical interval from fractions or segment confidence. +See `docs/pangram-4-1-investigation.md` for the verified sources and remaining gap. diff --git a/docs/pangram-4-1-investigation.md b/docs/pangram-4-1-investigation.md new file mode 100644 index 0000000..d2dab10 --- /dev/null +++ b/docs/pangram-4-1-investigation.md @@ -0,0 +1,39 @@ +# Pangram 4.1: verified changes and client scope + +Checked 2026-10-19. This is an implementation boundary, not a claim of complete +live API conformance or an independently verified accuracy benchmark. + +## Announced changes + +The [release post](https://x.com/max_spero_/status/2112249405684191350) announces +recalibrated confidence and a dashboard estimated AI-percentage range for +less-certain documents. The [short-scan follow-up](https://x.com/max_spero_/status/2112249407156388173) +adds a lower-certainty notice below 150 words. The [accuracy follow-up](https://x.com/max_spero_/status/2112249408888660063) +reports improvements to previously reported false positives. These are distinct +changes; the first screenshot alone was not the complete announcement. + +## Implemented here + +Pretty, Markdown and TUI AI results display a shared short-text caution based on +the returned segment word counts. No numerical range is fabricated. Existing +classification, fractions and per-segment confidence are preserved, as are +machine-readable output schemas. The model's accuracy changes are server-side; +the CLI does not attempt to reproduce or recalibrate the detector locally. + +## Public API evidence and remaining gap + +The [AI Detection API](https://docs.pangram.com/api-reference/ai-detection) still +shows the 4.0 result contract, including categorical segment confidence. The +[Models API](https://docs.pangram.com/api-reference/models) documents an ordered, +account-specific selector catalog; a model product name is not proof of a new +request selector. The public SDK at +[e8d737a](https://github.com/pangramlabs/pangram-sdk/commit/e8d737abf513c20808d3665025b76caa6ccfbdb9) +and public documentation at +[19c9200](https://github.com/pangramlabs/docs/commit/19c920036efe8344d75eb8dabba77083fda9eaf9) +do not yet define 4.1 range fields or a changed response-version string. + +Accordingly, this patch does not invent a `pangram-4.1` selector, weaken the +version/shape guard, or claim that a dashboard range is available through the +public API. An actual 4.1 wire contract remains to be confirmed before those +changes can ship. No dashboard scraping, browser-session authentication, +provider credits, or public result links are used for verification. diff --git a/src/domain.rs b/src/domain.rs index 40373d4..9d16d67 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -17,11 +17,13 @@ use uuid::{Uuid, Version}; mod bulk; mod collection; +mod detection_guidance; mod history; mod model; pub use bulk::*; pub use collection::*; +pub use detection_guidance::short_text_caution; pub use history::*; pub use model::*; diff --git a/src/domain/detection_guidance.rs b/src/domain/detection_guidance.rs new file mode 100644 index 0000000..e175313 --- /dev/null +++ b/src/domain/detection_guidance.rs @@ -0,0 +1,59 @@ +//! Interpretation guidance shared by human-readable projections. +//! +//! This is not a new detector, confidence calibration, or numerical interval. + +use super::Segment; + +const SHORT_TEXT_CAUTION: &str = + "Short text (under 150 words): detection is less certain. Treat this estimate cautiously."; + +/// Returns the published short-scan caution for a complete returned segment +/// sequence. Missing evidence and overflowing counts remain unknown. +#[must_use] +pub fn short_text_caution(segments: &[Segment]) -> Option<&'static str> { + if segments.is_empty() { + return None; + } + let words = segments.iter().try_fold(0_u64, |total, segment| { + total.checked_add(segment.word_count) + }); + match words { + Some(0..=149) => Some(SHORT_TEXT_CAUTION), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Confidence, Fraction, NonEmptyString}; + + fn segment(words: u64) -> Segment { + Segment { + text: "synthetic".to_owned(), + label: NonEmptyString::new("Human Written").unwrap(), + ai_assistance_score: Fraction::new(0.0).unwrap(), + confidence: Confidence::High, + start_index: 0, + end_index: 9, + word_count: words, + token_length: words, + humanizer_score: None, + is_humanized: None, + } + } + + #[test] + fn threshold_uses_the_document_total_not_individual_segment_lengths() { + assert!(short_text_caution(&[segment(149)]).is_some()); + assert!(short_text_caution(&[segment(50), segment(99)]).is_some()); + assert!(short_text_caution(&[segment(150)]).is_none()); + assert!(short_text_caution(&[segment(75), segment(75)]).is_none()); + } + + #[test] + fn missing_or_overflowing_counts_do_not_claim_a_short_document() { + assert!(short_text_caution(&[]).is_none()); + assert!(short_text_caution(&[segment(u64::MAX), segment(1)]).is_none()); + } +} diff --git a/src/output/projection.rs b/src/output/projection.rs index 0d65784..f41e596 100644 --- a/src/output/projection.rs +++ b/src/output/projection.rs @@ -477,6 +477,9 @@ fn write_ai_result( ) -> io::Result<()> { writer.label_value("headline", &writer.escape(&result.headline))?; writer.label_value("prediction", &writer.escape(&result.prediction))?; + if let Some(caution) = crate::domain::short_text_caution(&result.segments) { + writer.label_value("interpretation_note", caution)?; + } writer.label_value( "classification", &writer.token(classification_label(result.classification)), diff --git a/src/tui/result-lines.rs b/src/tui/result-lines.rs index 5947ffb..02a628a 100644 --- a/src/tui/result-lines.rs +++ b/src/tui/result-lines.rs @@ -88,6 +88,9 @@ pub(crate) fn analysis_result_lines( ), ])); lines.push(Line::styled(sanitize_single_line(&result.prediction), body)); + if let Some(caution) = crate::domain::short_text_caution(&result.segments) { + lines.push(Line::styled(caution, muted)); + } // The fractions row doubles as the legend for the bar below // it. Pangram's per-kind segment counts ride along muted as // AI/AI-assisted/human; the segment list itself is the evidence. @@ -634,8 +637,8 @@ mod tests { assert_eq!( text.len(), - 20, - "segments read as paragraphs, then two facts rows each" + 21, + "short-text caution precedes segment paragraphs and facts" ); assert_eq!( text, @@ -645,6 +648,7 @@ mod tests { "AI detection", "Mixed - Mixed [31m authorship", "The document contains mixed authorship.", + "Short text (under 150 words): detection is less certain. Treat this estimate cautiously.", "AI 50.0% AI-assisted 25.0% Human 25.0% 2 segments (1/0/1)", &format!("{}{}", "#".repeat(43), ".".repeat(17)), "", diff --git a/tests/projection-contract.rs b/tests/projection-contract.rs index fefb117..d7b89ec 100644 --- a/tests/projection-contract.rs +++ b/tests/projection-contract.rs @@ -624,3 +624,14 @@ fn jsonl_rejects_an_empty_series() { let result = render(OutputFormat::Jsonl, ColorPolicy::Plain, &[], &mut sink); assert!(result.is_err(), "JSONL accepted zero envelopes"); } + +#[test] +fn short_ai_results_include_an_interpretation_caution_in_human_formats_only() { + let envelope = success_envelope(); + for format in [OutputFormat::Pretty, OutputFormat::Markdown] { + let text = render_string(format, ColorPolicy::Plain, std::slice::from_ref(&envelope)); + assert!(text.contains("Short text (under 150 words): detection is less certain.")); + } + let json = render_string(OutputFormat::Json, ColorPolicy::Plain, &[envelope]); + assert!(!json.contains("interpretation_note")); +} diff --git a/tests/support/golden/markdown_success.md b/tests/support/golden/markdown_success.md index 4db4a7a..963c29f 100644 --- a/tests/support/golden/markdown_success.md +++ b/tests/support/golden/markdown_success.md @@ -20,6 +20,7 @@ - **upstream_stage:** STAGE_SUCCESS - **headline:** Human-written - **prediction:** The document appears to be human-written. +- **interpretation_note:** Short text (under 150 words): detection is less certain. Treat this estimate cautiously. - **classification:** human - **fraction_ai:** 0 - **fraction_ai_assisted:** 0 diff --git a/tests/support/golden/pretty_success.txt b/tests/support/golden/pretty_success.txt index 6a900a8..9433286 100644 --- a/tests/support/golden/pretty_success.txt +++ b/tests/support/golden/pretty_success.txt @@ -20,6 +20,7 @@ Checks upstream_stage: STAGE_SUCCESS headline: Human-written prediction: The document appears to be human-written. + interpretation_note: Short text (under 150 words): detection is less certain. Treat this estimate cautiously. classification: human fraction_ai: 0 fraction_ai_assisted: 0 diff --git a/tests/support/golden/pretty_success_color.txt b/tests/support/golden/pretty_success_color.txt index 3dbde1d..773bb2e 100644 --- a/tests/support/golden/pretty_success_color.txt +++ b/tests/support/golden/pretty_success_color.txt @@ -20,6 +20,7 @@ upstream_stage: STAGE_SUCCESS headline: Human-written prediction: The document appears to be human-written. + interpretation_note: Short text (under 150 words): detection is less certain. Treat this estimate cautiously. classification: human fraction_ai: 0 fraction_ai_assisted: 0 diff --git a/tools/tui-acceptance/__snapshots__/tui-acceptance.test.ts.snap b/tools/tui-acceptance/__snapshots__/tui-acceptance.test.ts.snap index 65b7807..e0c1db1 100644 --- a/tools/tui-acceptance/__snapshots__/tui-acceptance.test.ts.snap +++ b/tools/tui-acceptance/__snapshots__/tui-acceptance.test.ts.snap @@ -666,28 +666,28 @@ exports[`compiled TUI acceptance > submits through the shared analyzer and rende 00 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] 01 text=" Pangram | Analyze Inspector" cells=[[1,7,"Pangram","#c9d1d9 #0d1117 bold"],[14,1,"|","#c9d1d9 #0d1117"],[17,7,"Analyze","#c9d1d9 #0d1117 bold"],[97,9,"Inspector","#c9d1d9 #0d1117 bold"]] 02 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] -03 text=" * Analyze | Result rows 1-19 of 19 Public link off" cells=[[1,10,"* Analyze ","#c9d1d9 #0d1117 bold"],[14,1,"|","#c9d1d9 #0d1117"],[19,6,"Result","#c9d1d9 #0d1117"],[26,4,"rows","#c9d1d9 #0d1117"],[31,4,"1-19","#c9d1d9 #0d1117"],[36,2,"of","#c9d1d9 #0d1117"],[39,2,"19","#c9d1d9 #0d1117"],[97,1," ","#c9d1d9 #0d1117 bold"],[99,6,"Public","#c9d1d9 #0d1117"],[106,4,"link","#c9d1d9 #0d1117"],[111,3,"off","#c9d1d9 #0d1117"]] +03 text=" * Analyze | Result rows 1-21 of 21 Public link off" cells=[[1,10,"* Analyze ","#c9d1d9 #0d1117 bold"],[14,1,"|","#c9d1d9 #0d1117"],[19,6,"Result","#c9d1d9 #0d1117"],[26,4,"rows","#c9d1d9 #0d1117"],[31,4,"1-21","#c9d1d9 #0d1117"],[36,2,"of","#c9d1d9 #0d1117"],[39,2,"21","#c9d1d9 #0d1117"],[97,1," ","#c9d1d9 #0d1117 bold"],[99,6,"Public","#c9d1d9 #0d1117"],[106,4,"link","#c9d1d9 #0d1117"],[111,3,"off","#c9d1d9 #0d1117"]] 04 text=" | > Succeeded anl_00000000-0000-0000-0000-000000000000" cells=[[14,1,"|","#c9d1d9 #0d1117"],[17,11,"> Succeeded","#c9d1d9 #0d1117 bold"],[30,40,"anl_00000000-0000-0000-0000-000000000000","#c9d1d9 #0d1117"]] 05 text=" Active | Manual save off" cells=[[1,1," ","#c9d1d9 #0d1117 bold"],[3,6,"Active","#c9d1d9 #0d1117"],[14,1,"|","#c9d1d9 #0d1117"],[97,1," ","#c9d1d9 #0d1117 bold"],[99,6,"Manual","#c9d1d9 #0d1117"],[106,4,"save","#c9d1d9 #0d1117"],[111,3,"off","#c9d1d9 #0d1117"]] 06 text=" | AI detection" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,12,"AI detection","#c9d1d9 #0d1117 bold"]] 07 text=" History | Human - Human-written Highlight off" cells=[[1,1," ","#c9d1d9 #0d1117 bold"],[3,7,"History","#c9d1d9 #0d1117"],[14,1,"|","#c9d1d9 #0d1117"],[19,21,"Human - Human-written","#c9d1d9 #0d1117 bold"],[97,1," ","#c9d1d9 #0d1117 bold"],[99,9,"Highlight","#c9d1d9 #0d1117"],[109,3,"off","#c9d1d9 #0d1117"]] 08 text=" | The document appears to be human-written." cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,3,"The","#c9d1d9 #0d1117"],[23,8,"document","#c9d1d9 #0d1117"],[32,7,"appears","#c9d1d9 #0d1117"],[40,2,"to","#c9d1d9 #0d1117"],[43,2,"be","#c9d1d9 #0d1117"],[46,14,"human-written.","#c9d1d9 #0d1117"]] -09 text=" Settings | AI 0.0% AI-assisted 0.0% Human 100.0% 1 segment (0/0/1)" cells=[[1,1," ","#c9d1d9 #0d1117 bold"],[3,8,"Settings","#c9d1d9 #0d1117"],[14,1,"|","#c9d1d9 #0d1117"],[19,2,"AI","#c9d1d9 #0d1117 bold"],[22,4,"0.0%","#c9d1d9 #0d1117"],[26,13," AI-assisted","#c9d1d9 #0d1117 bold"],[40,4,"0.0%","#c9d1d9 #0d1117"],[44,7," Human","#c9d1d9 #0d1117 bold"],[52,6,"100.0%","#c9d1d9 #0d1117"],[60,1,"1","#c9d1d9 #0d1117"],[62,7,"segment","#c9d1d9 #0d1117"],[70,7,"(0/0/1)","#c9d1d9 #0d1117"]] -10 text=" | ............................................................ Words 8" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,60,"............................................................","#c9d1d9 #0d1117"],[99,5,"Words","#c9d1d9 #0d1117"],[105,1,"8","#c9d1d9 #0d1117"]] -11 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] -12 text=" | This synthetic terminal journey remains human written today Estimate 1 unit" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,4,"This","#c9d1d9 #0d1117"],[24,9,"synthetic","#c9d1d9 #0d1117"],[34,8,"terminal","#c9d1d9 #0d1117"],[43,7,"journey","#c9d1d9 #0d1117"],[51,7,"remains","#c9d1d9 #0d1117"],[59,5,"human","#c9d1d9 #0d1117"],[65,7,"written","#c9d1d9 #0d1117"],[73,5,"today","#c9d1d9 #0d1117"],[99,8,"Estimate","#c9d1d9 #0d1117"],[108,1,"1","#c9d1d9 #0d1117"],[110,4,"unit","#c9d1d9 #0d1117"]] +09 text=" Settings | Short text (under 150 words): detection is less certain. Treat this" cells=[[1,1," ","#c9d1d9 #0d1117 bold"],[3,8,"Settings","#c9d1d9 #0d1117"],[14,1,"|","#c9d1d9 #0d1117"],[19,5,"Short","#c9d1d9 #0d1117"],[25,4,"text","#c9d1d9 #0d1117"],[30,6,"(under","#c9d1d9 #0d1117"],[37,3,"150","#c9d1d9 #0d1117"],[41,7,"words):","#c9d1d9 #0d1117"],[49,9,"detection","#c9d1d9 #0d1117"],[59,2,"is","#c9d1d9 #0d1117"],[62,4,"less","#c9d1d9 #0d1117"],[67,8,"certain.","#c9d1d9 #0d1117"],[76,5,"Treat","#c9d1d9 #0d1117"],[82,4,"this","#c9d1d9 #0d1117"]] +10 text=" | estimate cautiously. Words 8" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,8,"estimate","#c9d1d9 #0d1117"],[28,11,"cautiously.","#c9d1d9 #0d1117"],[99,5,"Words","#c9d1d9 #0d1117"],[105,1,"8","#c9d1d9 #0d1117"]] +11 text=" | AI 0.0% AI-assisted 0.0% Human 100.0% 1 segment (0/0/1)" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,2,"AI","#c9d1d9 #0d1117 bold"],[22,4,"0.0%","#c9d1d9 #0d1117"],[26,13," AI-assisted","#c9d1d9 #0d1117 bold"],[40,4,"0.0%","#c9d1d9 #0d1117"],[44,7," Human","#c9d1d9 #0d1117 bold"],[52,6,"100.0%","#c9d1d9 #0d1117"],[60,1,"1","#c9d1d9 #0d1117"],[62,7,"segment","#c9d1d9 #0d1117"],[70,7,"(0/0/1)","#c9d1d9 #0d1117"]] +12 text=" | ............................................................ Estimate 1 unit" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,60,"............................................................","#c9d1d9 #0d1117"],[99,8,"Estimate","#c9d1d9 #0d1117"],[108,1,"1","#c9d1d9 #0d1117"],[110,4,"unit","#c9d1d9 #0d1117"]] 13 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] -14 text=" | 1 Human Written 0.0% AI assistance high confidence" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,1,"1","#c9d1d9 #0d1117"],[22,13,"Human Written","#c9d1d9 #0d1117 bold"],[37,4,"0.0%","#c9d1d9 #0d1117"],[42,2,"AI","#c9d1d9 #0d1117"],[45,10,"assistance","#c9d1d9 #0d1117"],[57,4,"high","#c9d1d9 #0d1117"],[62,10,"confidence","#c9d1d9 #0d1117"]] -15 text=" | 0..59 8 words 8 tokens humanizer 0.0% New analysis" cells=[[14,1,"|","#c9d1d9 #0d1117"],[22,5,"0..59","#c9d1d9 #0d1117"],[29,1,"8","#c9d1d9 #0d1117"],[31,5,"words","#c9d1d9 #0d1117"],[38,1,"8","#c9d1d9 #0d1117"],[40,6,"tokens","#c9d1d9 #0d1117"],[48,9,"humanizer","#c9d1d9 #0d1117"],[58,4,"0.0%","#c9d1d9 #0d1117"],[97,15," New analysis ","#c9d1d9 #0d1117 bold"]] -16 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] -17 text=" | Provider Pangram version 4.0" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,8,"Provider","#c9d1d9 #0d1117"],[28,7,"Pangram","#c9d1d9 #0d1117"],[37,7,"version","#c9d1d9 #0d1117"],[45,3,"4.0","#c9d1d9 #0d1117"]] -18 text=" | Upstream tasks task-terminal-control" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,8,"Upstream","#c9d1d9 #0d1117"],[28,5,"tasks","#c9d1d9 #0d1117"],[34,21,"task-terminal-control","#c9d1d9 #0d1117"]] -19 text=" | Submitted [timestamp]" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,9,"Submitted","#c9d1d9 #0d1117"],[29,11,"[timestamp]","#c9d1d9 #0d1117"]] -20 text=" | Completed [timestamp]" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,9,"Completed","#c9d1d9 #0d1117"],[29,11,"[timestamp]","#c9d1d9 #0d1117"]] -21 text=" | AI detection task task-terminal-control" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,2,"AI","#c9d1d9 #0d1117"],[22,9,"detection","#c9d1d9 #0d1117"],[32,4,"task","#c9d1d9 #0d1117"],[37,21,"task-terminal-control","#c9d1d9 #0d1117"]] -22 text=" | Save state ephemeral" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,4,"Save","#c9d1d9 #0d1117"],[24,5,"state","#c9d1d9 #0d1117"],[31,9,"ephemeral","#c9d1d9 #0d1117"]] -23 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] -24 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] +14 text=" | This synthetic terminal journey remains human written today" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,4,"This","#c9d1d9 #0d1117"],[24,9,"synthetic","#c9d1d9 #0d1117"],[34,8,"terminal","#c9d1d9 #0d1117"],[43,7,"journey","#c9d1d9 #0d1117"],[51,7,"remains","#c9d1d9 #0d1117"],[59,5,"human","#c9d1d9 #0d1117"],[65,7,"written","#c9d1d9 #0d1117"],[73,5,"today","#c9d1d9 #0d1117"]] +15 text=" | New analysis" cells=[[14,1,"|","#c9d1d9 #0d1117"],[97,15," New analysis ","#c9d1d9 #0d1117 bold"]] +16 text=" | 1 Human Written 0.0% AI assistance high confidence" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,1,"1","#c9d1d9 #0d1117"],[22,13,"Human Written","#c9d1d9 #0d1117 bold"],[37,4,"0.0%","#c9d1d9 #0d1117"],[42,2,"AI","#c9d1d9 #0d1117"],[45,10,"assistance","#c9d1d9 #0d1117"],[57,4,"high","#c9d1d9 #0d1117"],[62,10,"confidence","#c9d1d9 #0d1117"]] +17 text=" | 0..59 8 words 8 tokens humanizer 0.0%" cells=[[14,1,"|","#c9d1d9 #0d1117"],[22,5,"0..59","#c9d1d9 #0d1117"],[29,1,"8","#c9d1d9 #0d1117"],[31,5,"words","#c9d1d9 #0d1117"],[38,1,"8","#c9d1d9 #0d1117"],[40,6,"tokens","#c9d1d9 #0d1117"],[48,9,"humanizer","#c9d1d9 #0d1117"],[58,4,"0.0%","#c9d1d9 #0d1117"]] +18 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] +19 text=" | Provider Pangram version 4.0" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,8,"Provider","#c9d1d9 #0d1117"],[28,7,"Pangram","#c9d1d9 #0d1117"],[37,7,"version","#c9d1d9 #0d1117"],[45,3,"4.0","#c9d1d9 #0d1117"]] +20 text=" | Upstream tasks task-terminal-control" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,8,"Upstream","#c9d1d9 #0d1117"],[28,5,"tasks","#c9d1d9 #0d1117"],[34,21,"task-terminal-control","#c9d1d9 #0d1117"]] +21 text=" | Submitted [timestamp]" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,9,"Submitted","#c9d1d9 #0d1117"],[29,11,"[timestamp]","#c9d1d9 #0d1117"]] +22 text=" | Completed [timestamp]" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,9,"Completed","#c9d1d9 #0d1117"],[29,11,"[timestamp]","#c9d1d9 #0d1117"]] +23 text=" | AI detection task task-terminal-control" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,2,"AI","#c9d1d9 #0d1117"],[22,9,"detection","#c9d1d9 #0d1117"],[32,4,"task","#c9d1d9 #0d1117"],[37,21,"task-terminal-control","#c9d1d9 #0d1117"]] +24 text=" | Save state ephemeral" cells=[[14,1,"|","#c9d1d9 #0d1117"],[19,4,"Save","#c9d1d9 #0d1117"],[24,5,"state","#c9d1d9 #0d1117"],[31,9,"ephemeral","#c9d1d9 #0d1117"]] 25 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] 26 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] 27 text=" |" cells=[[14,1,"|","#c9d1d9 #0d1117"]] diff --git a/tools/tui-acceptance/tui-acceptance.test.ts b/tools/tui-acceptance/tui-acceptance.test.ts index 506a948..a2307e0 100644 --- a/tools/tui-acceptance/tui-acceptance.test.ts +++ b/tools/tui-acceptance/tui-acceptance.test.ts @@ -292,7 +292,7 @@ describe.runIf(SUPPORTED_PLATFORM)("compiled TUI acceptance", () => { mode: "success", artifactName: "analysis-success", text: "This synthetic terminal journey remains human written today", - expected: ["Succeeded anl_", "Human - Human-written", "Human 100.0%"], + expected: ["Succeeded anl_", "Human - Human-written", "Human 100.0%", "Short text (under 150 words)"], }), ) From f274cb0cc43d572e6a4b11a08278e98b9ccaba29 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 19 Oct 2026 16:55:59 -0400 Subject: [PATCH 2/4] fix: isolate browser test types from the deployed docs build --- .github/workflows/browser.yml | 1 + docs-app/tsconfig.e2e.json | 13 +++++++++++++ docs-app/tsconfig.json | 24 ++++++++++++++++++++---- 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 docs-app/tsconfig.e2e.json diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index e40a997..db3811c 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -18,6 +18,7 @@ jobs: with: node-version: 24 - run: npm ci --prefix .. + - run: npx tsc --project tsconfig.e2e.json - run: npm run docs:build --prefix .. - run: npx e2e-web install chromium --with-deps - run: npm run test:e2e diff --git a/docs-app/tsconfig.e2e.json b/docs-app/tsconfig.e2e.json new file mode 100644 index 0000000..4226ee4 --- /dev/null +++ b/docs-app/tsconfig.e2e.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false + }, + "include": [ + "e2e.config.ts", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/docs-app/tsconfig.json b/docs-app/tsconfig.json index 18f0ad1..c481e35 100644 --- a/docs-app/tsconfig.json +++ b/docs-app/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": false, "skipLibCheck": true, "strict": true, @@ -14,8 +18,16 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, - "paths": { "@/*": ["./*"] }, - "plugins": [{ "name": "next" }] + "paths": { + "@/*": [ + "./*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] }, "include": [ "next-env.d.ts", @@ -24,5 +36,9 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "tests", + "e2e.config.ts" + ] } From dbd7dc69e26525c6d9fed1ccb2c2693df73969c5 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 19 Oct 2026 16:57:04 -0400 Subject: [PATCH 3/4] test: explicitly load Node types for the isolated E2E suite --- docs-app/tsconfig.e2e.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs-app/tsconfig.e2e.json b/docs-app/tsconfig.e2e.json index 4226ee4..5d8da1f 100644 --- a/docs-app/tsconfig.e2e.json +++ b/docs-app/tsconfig.e2e.json @@ -1,7 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "incremental": false + "incremental": false, + "types": [ + "node" + ] }, "include": [ "e2e.config.ts", From 6032aae232462882f44aa620717dea658c6c9411 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 19 Oct 2026 18:16:05 -0400 Subject: [PATCH 4/4] fix: require complete segment counters before short-text guidance --- docs/contracts.md | 6 ++- src/domain/detection_guidance.rs | 65 +++++++++++++++++++++++++++----- src/output/projection.rs | 2 +- src/tui/result-lines.rs | 2 +- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/contracts.md b/docs/contracts.md index 30d7cc2..4b77400 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -2480,8 +2480,10 @@ source. Human-readable AI result projections (pretty, Markdown, and TUI Analyze/History) show a short-text caution when the returned complete segment sequence has a -summed word count below 150. An absent segment sequence or overflowing count is -unknown and does not produce a short-text claim. The caution applies regardless +summed word count below 150. The returned segment count must equal the checked +sum of the AI, AI-assisted, and human segment counters. An absent sequence, +inconsistent segment counters, or overflowing count is unknown and does not +produce a short-text claim. The caution applies regardless of classification or segment confidence; a high-confidence segment does not cancel the document-length limitation. It does not alter scores, classifications, exit codes, billing, saved canonical data, or JSON/JSONL/TOON schemas. diff --git a/src/domain/detection_guidance.rs b/src/domain/detection_guidance.rs index e175313..42d39cc 100644 --- a/src/domain/detection_guidance.rs +++ b/src/domain/detection_guidance.rs @@ -2,7 +2,7 @@ //! //! This is not a new detector, confidence calibration, or numerical interval. -use super::Segment; +use super::AiDetectionResult; const SHORT_TEXT_CAUTION: &str = "Short text (under 150 words): detection is less certain. Treat this estimate cautiously."; @@ -10,8 +10,13 @@ const SHORT_TEXT_CAUTION: &str = /// Returns the published short-scan caution for a complete returned segment /// sequence. Missing evidence and overflowing counts remain unknown. #[must_use] -pub fn short_text_caution(segments: &[Segment]) -> Option<&'static str> { - if segments.is_empty() { +pub fn short_text_caution(result: &AiDetectionResult) -> Option<&'static str> { + let segments = &result.segments; + let reported = result + .num_ai_segments + .checked_add(result.num_ai_assisted_segments)? + .checked_add(result.num_human_segments)?; + if segments.is_empty() || u64::try_from(segments.len()).ok()? != reported { return None; } let words = segments.iter().try_fold(0_u64, |total, segment| { @@ -26,7 +31,7 @@ pub fn short_text_caution(segments: &[Segment]) -> Option<&'static str> { #[cfg(test)] mod tests { use super::*; - use crate::domain::{Confidence, Fraction, NonEmptyString}; + use crate::domain::{Confidence, Fraction, NonEmptyString, Segment}; fn segment(words: u64) -> Segment { Segment { @@ -43,17 +48,57 @@ mod tests { } } + #[test] + fn inconsistent_segment_counters_do_not_claim_a_short_document() { + let result = crate::domain::AiDetectionResult { + classification: crate::domain::AiClassification::Human, + headline: "Human".to_owned(), + prediction: "Human Written".to_owned(), + fraction_ai: Fraction::new(0.0).unwrap(), + fraction_ai_assisted: Fraction::new(0.0).unwrap(), + fraction_human: Fraction::new(1.0).unwrap(), + num_ai_segments: 0, + num_ai_assisted_segments: 0, + num_human_segments: 2, + segments: vec![segment(100)], + dashboard_link: None, + }; + assert!(short_text_caution(&result).is_none()); + let mut overflow = result.clone(); + overflow.num_ai_segments = u64::MAX; + assert!(short_text_caution(&overflow).is_none()); + let mut missing = result; + missing.num_human_segments = 0; + assert!(short_text_caution(&missing).is_none()); + } + + fn caution(segments: &[Segment]) -> Option<&'static str> { + short_text_caution(&AiDetectionResult { + classification: crate::domain::AiClassification::Human, + headline: "Human".to_owned(), + prediction: "Human Written".to_owned(), + fraction_ai: Fraction::new(0.0).unwrap(), + fraction_ai_assisted: Fraction::new(0.0).unwrap(), + fraction_human: Fraction::new(1.0).unwrap(), + num_ai_segments: 0, + num_ai_assisted_segments: 0, + num_human_segments: u64::try_from(segments.len()).unwrap(), + segments: segments.to_vec(), + dashboard_link: None, + }) + } + #[test] fn threshold_uses_the_document_total_not_individual_segment_lengths() { - assert!(short_text_caution(&[segment(149)]).is_some()); - assert!(short_text_caution(&[segment(50), segment(99)]).is_some()); - assert!(short_text_caution(&[segment(150)]).is_none()); - assert!(short_text_caution(&[segment(75), segment(75)]).is_none()); + assert!(caution(&[segment(149)]).is_some()); + assert!(caution(&[segment(50), segment(99)]).is_some()); + assert!(caution(&[segment(150)]).is_none()); + assert!(caution(&[segment(75), segment(75)]).is_none()); } #[test] fn missing_or_overflowing_counts_do_not_claim_a_short_document() { - assert!(short_text_caution(&[]).is_none()); - assert!(short_text_caution(&[segment(u64::MAX), segment(1)]).is_none()); + assert!(caution(&[]).is_none()); + assert!(caution(&[segment(u64::MAX), segment(1)]).is_none()); } } diff --git a/src/output/projection.rs b/src/output/projection.rs index f41e596..74107bb 100644 --- a/src/output/projection.rs +++ b/src/output/projection.rs @@ -477,7 +477,7 @@ fn write_ai_result( ) -> io::Result<()> { writer.label_value("headline", &writer.escape(&result.headline))?; writer.label_value("prediction", &writer.escape(&result.prediction))?; - if let Some(caution) = crate::domain::short_text_caution(&result.segments) { + if let Some(caution) = crate::domain::short_text_caution(result) { writer.label_value("interpretation_note", caution)?; } writer.label_value( diff --git a/src/tui/result-lines.rs b/src/tui/result-lines.rs index 02a628a..ff2f5a7 100644 --- a/src/tui/result-lines.rs +++ b/src/tui/result-lines.rs @@ -88,7 +88,7 @@ pub(crate) fn analysis_result_lines( ), ])); lines.push(Line::styled(sanitize_single_line(&result.prediction), body)); - if let Some(caution) = crate::domain::short_text_caution(&result.segments) { + if let Some(caution) = crate::domain::short_text_caution(result) { lines.push(Line::styled(caution, muted)); } // The fractions row doubles as the legend for the bar below