formal_ai/summarization/mod.rs
1//! Formalize-summarize-deformalize pipeline for project descriptions, README
2//! prose, conversation summaries, and chat titles.
3//!
4//! The module is intentionally deterministic: every transformation is a pure
5//! function of its input plus the [`SummarizationConfig`]. No neural model
6//! or external API is consulted. The pipeline has three explicit stages:
7//!
8//! 1. **Formalize.** Free-form prose, Markdown README content, dialog turns,
9//! or a curated list of [`crate::seed::ProjectStatement`]s is converted
10//! into a homogeneous `Vec<Statement>`. Each statement is one sentence
11//! with a coarse `kind` inferred from cue words (purpose, feature,
12//! install, …) and a numeric `weight` (0–100) that says how important it
13//! is.
14//! 2. **Summarize.** [`summarize`] applies the configured [`SummarizationMode`]
15//! and `max_statements` limit. Compressing keeps the highest-weighted
16//! statements; expanding *adds* paraphrases generated from the NSM
17//! semantic-prime expansion below.
18//! 3. **Deformalize.** [`deformalize`] renders the surviving statements back
19//! into a single block of text suitable for display.
20//!
21//! The `apply_semantic_primes` and `apply_compound_words` helpers implement
22//! the configurable "simplify with semantic primes / shorten with compound
23//! words" requirement from PR #174. Both are vocabulary-driven so they can be
24//! extended without touching call sites.
25//!
26//! Higher-level helpers chain the three stages together for the most common
27//! callers:
28//!
29//! - [`describe_project`] — curated GitHub project → language-aware
30//! description.
31//! - [`describe_readme`] — Markdown README text → language-aware description
32//! (badges, headings, and fenced code blocks are stripped before
33//! formalization).
34//! - [`summarize_dialog`] — chat turns → short recap of the conversation.
35//! - [`generate_chat_title`] — chat turns → 1–5 word chat title.
36//! - [`summarize_repository_file`] — repository path + file content → file
37//! metadata, optional meta-language evidence, embedded Markdown grammars, and
38//! content summary.
39//! - [`summarize_repository_resource`] — a [`RepositoryEntry`] tree (a file
40//! **or** a directory/folder of arbitrary depth) → a recursively composed
41//! summary. Directories decompose into children, summarize each child
42//! (recursing into subdirectories), and compose the child summaries behind an
43//! aggregate identity sentence, bounding depth via the mode ladder. This is
44//! the general entry point that subsumes [`summarize_repository_file`].
45//!
46//! See `ARCHITECTURE.md` § "Project lookups and summarization" for how
47//! `project_lookup` chains the three stages together.
48
49use crate::seed::{ProjectRecord, ProjectStatement};
50
51/// Default cap on the number of retained statements per summary.
52///
53/// Applied when the caller does not supply an explicit `max_statements`
54/// value. Mirrors the vision note in PR #174: "for example not more than
55/// 30 statements (it should be configurable also)". Set via
56/// [`SummarizationConfig::default`] callers that opt into the cap.
57pub const DEFAULT_MAX_STATEMENTS: usize = 30;
58
59/// Coarse classification used by the summarizer to decide which statements
60/// survive a compression pass. Mirrors the `kind "..."` field accepted by
61/// `data/seed/projects.lino`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum StatementKind {
64 /// "X is Y" — the bare identity of the project / subject.
65 Identity,
66 /// Why the project exists / what problem it solves.
67 Purpose,
68 /// Programming language or runtime.
69 Language,
70 /// Star count or other social proof.
71 Stars,
72 /// A concrete capability the project offers.
73 Feature,
74 /// When the reader should reach for the project.
75 UseCase,
76 /// Installation / setup instructions.
77 Install,
78 /// Example invocation, code snippet, command-line usage.
79 Example,
80 /// Anything else (treated as low-weight by default).
81 #[default]
82 Misc,
83}
84
85impl StatementKind {
86 /// Parse a kind label from a seed `kind "..."` field. Unknown labels
87 /// fall back to [`StatementKind::Misc`] so the data file remains forward-
88 /// compatible with new kinds added in code.
89 #[must_use]
90 pub fn parse(value: &str) -> Self {
91 match value.trim().to_ascii_lowercase().as_str() {
92 "identity" => Self::Identity,
93 "purpose" => Self::Purpose,
94 "language" => Self::Language,
95 "stars" => Self::Stars,
96 "feature" => Self::Feature,
97 "use_case" | "usecase" | "use-case" => Self::UseCase,
98 "install" => Self::Install,
99 "example" => Self::Example,
100 _ => Self::Misc,
101 }
102 }
103
104 /// Map the slug of a `summary_classification_cue` meaning to its kind. The
105 /// seven `summary_kind_*` leaves in `data/seed/meanings-summary.lino` carry
106 /// the cue surfaces that [`classify_sentence`] scans; this resolves the
107 /// meaning that matched back into a [`StatementKind`]. Unknown slugs fall
108 /// back to [`StatementKind::Misc`] so the seed stays forward-compatible.
109 #[must_use]
110 pub fn from_slug(slug: &str) -> Self {
111 match slug {
112 "summary_kind_install" => Self::Install,
113 "summary_kind_example" => Self::Example,
114 "summary_kind_language" => Self::Language,
115 "summary_kind_stars" => Self::Stars,
116 "summary_kind_purpose" => Self::Purpose,
117 "summary_kind_use_case" => Self::UseCase,
118 "summary_kind_feature" => Self::Feature,
119 _ => Self::Misc,
120 }
121 }
122
123 /// `true` when the statement carries information that survives the
124 /// tightest "what is X?" responses (identity, purpose, language, stars).
125 #[must_use]
126 pub const fn is_essential(self) -> bool {
127 matches!(
128 self,
129 Self::Identity | Self::Purpose | Self::Language | Self::Stars
130 )
131 }
132
133 /// `true` when the statement is README boilerplate (install / example)
134 /// that should be omitted from compressed answers.
135 #[must_use]
136 pub const fn is_boilerplate(self) -> bool {
137 matches!(self, Self::Install | Self::Example)
138 }
139}
140
141/// A single normalized statement participating in the summarization pipeline.
142#[derive(Debug, Clone)]
143pub struct Statement {
144 pub text: String,
145 pub kind: StatementKind,
146 pub weight: u8,
147}
148
149impl Statement {
150 /// Build a statement from explicit fields.
151 #[must_use]
152 pub fn new(text: impl Into<String>, kind: StatementKind, weight: u8) -> Self {
153 Self {
154 text: text.into(),
155 kind,
156 weight,
157 }
158 }
159
160 /// Build a statement from a seed [`ProjectStatement`], inferring the
161 /// numeric kind from the seed's text label and clamping the weight.
162 #[must_use]
163 pub fn from_seed(seed: &ProjectStatement) -> Self {
164 Self {
165 text: seed.text.clone(),
166 kind: StatementKind::parse(&seed.kind),
167 weight: seed.weight,
168 }
169 }
170}
171
172/// Compression / expansion target for [`summarize`].
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum SummarizationMode {
175 /// 1–5 words — return just the project / topic name. Used for chat titles
176 /// and topic labels.
177 Topic,
178 /// ~20% of the source — one or two essential statements.
179 Short,
180 /// ~50% of the source — keep all essential statements plus the highest-
181 /// weighted features.
182 #[default]
183 Standard,
184 /// 100% — every statement, in weight order.
185 Full,
186 /// ~200% — every statement plus NSM-style paraphrases that expand
187 /// compound words into semantic primes.
188 Expand,
189}
190
191impl SummarizationMode {
192 /// Target size relative to the input statement count, expressed as a
193 /// percentage. Used by [`SummarizationConfig::effective_max_statements`]
194 /// when the caller does not pin an explicit cap. Integer math keeps the
195 /// pipeline free of floating-point casts.
196 #[must_use]
197 pub const fn target_percent(self) -> u32 {
198 match self {
199 Self::Topic => 0,
200 Self::Short => 20,
201 Self::Standard => 50,
202 Self::Full => 100,
203 Self::Expand => 200,
204 }
205 }
206
207 /// The next-shorter mode on the detail ladder, used to bound recursion when
208 /// composing nested summaries (a directory describes its children one mode
209 /// shorter than itself). `Topic` is the fixed point: it cannot get shorter.
210 #[must_use]
211 pub const fn one_step_shorter(self) -> Self {
212 match self {
213 Self::Expand | Self::Full => Self::Standard,
214 Self::Standard => Self::Short,
215 Self::Short | Self::Topic => Self::Topic,
216 }
217 }
218}
219
220/// Configuration for the summarization pipeline. Every knob has a sensible
221/// default so the simplest call site can be
222/// `summarize(&statements, &SummarizationConfig::default())`.
223#[derive(Debug, Clone)]
224pub struct SummarizationConfig {
225 pub mode: SummarizationMode,
226 /// Hard cap on output statements. `None` lets [`SummarizationMode`] pick.
227 pub max_statements: Option<usize>,
228 /// Language slug (`en` / `ru` / `hi` / `zh`). Drives compound-word and
229 /// semantic-prime substitution lists.
230 pub language: String,
231 /// Replace compound words with shorter compound forms (default `false`).
232 /// Useful for chat titles where the result should fit in 1–5 words.
233 pub use_compound_words: bool,
234 /// Expand compound or rare words into NSM semantic primes when the mode
235 /// is `Expand`. Off by default to keep `Topic`/`Short`/`Standard` terse.
236 pub use_semantic_primes: bool,
237 /// Strip boilerplate kinds (`install`, `example`) from the output.
238 /// `true` by default — compressed answers should never carry setup steps.
239 pub drop_boilerplate: bool,
240}
241
242impl Default for SummarizationConfig {
243 fn default() -> Self {
244 Self {
245 mode: SummarizationMode::Standard,
246 max_statements: None,
247 language: "en".to_string(),
248 use_compound_words: false,
249 use_semantic_primes: false,
250 drop_boilerplate: true,
251 }
252 }
253}
254
255impl SummarizationConfig {
256 /// Builder helper used by project lookup call sites.
257 #[must_use]
258 pub const fn with_mode(mut self, mode: SummarizationMode) -> Self {
259 self.mode = mode;
260 self
261 }
262
263 /// Builder helper to pin the language.
264 #[must_use]
265 pub fn with_language(mut self, language: impl Into<String>) -> Self {
266 self.language = language.into();
267 self
268 }
269
270 /// Builder helper to clamp the number of statements.
271 #[must_use]
272 pub const fn with_max_statements(mut self, cap: usize) -> Self {
273 self.max_statements = Some(cap);
274 self
275 }
276
277 /// Effective statement cap for the given input size. Combines
278 /// [`SummarizationMode::target_percent`] with the optional explicit cap and
279 /// guarantees at least one statement for any non-empty input.
280 #[must_use]
281 pub fn effective_max_statements(&self, input_count: usize) -> usize {
282 if input_count == 0 {
283 return 0;
284 }
285 let ratio_target = match self.mode {
286 // Topic mode is rendered separately, but still returns at most 1
287 // statement when summarize() is asked to enforce it.
288 SummarizationMode::Topic => 1,
289 SummarizationMode::Full | SummarizationMode::Expand => input_count,
290 other => {
291 // Round-to-nearest using only integer math:
292 // suggested = round(input_count * percent / 100)
293 let percent = other.target_percent() as usize;
294 let suggested = (input_count * percent + 50) / 100;
295 suggested.max(1)
296 }
297 };
298 self.max_statements
299 .map_or_else(|| ratio_target.max(1), |cap| cap.min(ratio_target).max(1))
300 }
301}
302
303/// Split a paragraph of free-form text into [`Statement`]s. Each sentence
304/// ends at `.`, `!`, `?`, `。`, `…`, the Devanagari danda `।` or double danda
305/// `॥`, or a newline. Empty fragments are dropped.
306///
307/// The dandas matter because Hindi prose ends its sentences with them and not
308/// with a full stop. Without them a Hindi page is one enormous statement, so
309/// anything that ranks or trims sentences — the web-research extract of issue
310/// #771, for one — degrades to returning the whole document.
311#[must_use]
312pub fn formalize(text: &str) -> Vec<Statement> {
313 let mut out = Vec::new();
314 let mut buffer = String::new();
315 for ch in text.chars() {
316 buffer.push(ch);
317 if matches!(ch, '.' | '!' | '?' | '。' | '…' | '।' | '॥' | '\n') {
318 push_sentence(&mut buffer, &mut out);
319 }
320 }
321 push_sentence(&mut buffer, &mut out);
322 out
323}
324
325fn push_sentence(buffer: &mut String, out: &mut Vec<Statement>) {
326 let sentence: String = buffer
327 .chars()
328 .filter(|c| !matches!(c, '\n'))
329 .collect::<String>()
330 .trim()
331 .to_string();
332 buffer.clear();
333 if sentence.is_empty() {
334 return;
335 }
336 let kind = classify_sentence(&sentence);
337 let weight = weight_for_kind(kind);
338 out.push(Statement::new(sentence, kind, weight));
339}
340
341/// Heuristic classifier for prose sentences.
342///
343/// Reasons over the seed registry rather than any hardcoded cue list: it walks
344/// the meanings carrying [`crate::seed::ROLE_SUMMARY_CLASSIFICATION_CUE`] in
345/// declaration order and returns the kind of the first meaning whose surface
346/// fragments occur in the lowercased sentence as a raw substring. The `language`
347/// kind is additionally length-guarded — a sentence that merely contains a
348/// language cue but runs past twelve whitespace words is not a language line, so
349/// it falls through to the later kinds. Every cue fragment, in every supported
350/// language, lives in `data/seed/meanings-summary.lino`; nothing is hardcoded here.
351#[must_use]
352pub fn classify_sentence(sentence: &str) -> StatementKind {
353 let lower = sentence.to_lowercase();
354 let word_count = lower.split_whitespace().count();
355 for meaning in
356 crate::seed::lexicon().meanings_with_role(crate::seed::ROLE_SUMMARY_CLASSIFICATION_CUE)
357 {
358 if !meaning.words().any(|cue| lower.contains(cue)) {
359 continue;
360 }
361 let kind = StatementKind::from_slug(&meaning.slug);
362 // The `language` kind only applies to short identity-style sentences; a
363 // long sentence that merely contains `is a …` keeps scanning so a later
364 // feature/purpose cue can claim it (preserving the original
365 // `&& word_count <= 12` guard that sat on the language arm).
366 if kind == StatementKind::Language && word_count > 12 {
367 continue;
368 }
369 return kind;
370 }
371 StatementKind::Misc
372}
373
374const fn weight_for_kind(kind: StatementKind) -> u8 {
375 match kind {
376 StatementKind::Purpose => 100,
377 StatementKind::Identity => 90,
378 StatementKind::Language => 60,
379 StatementKind::Stars => 55,
380 StatementKind::Feature => 70,
381 StatementKind::UseCase => 65,
382 StatementKind::Install => 10,
383 StatementKind::Example => 15,
384 StatementKind::Misc => 30,
385 }
386}
387
388/// Apply [`SummarizationConfig`] to a slice of statements.
389///
390/// Returns a new vector ordered by weight (descending), capped at the effective
391/// max. Boilerplate is stripped before ranking when `drop_boilerplate` is set,
392/// and `Expand` mode appends NSM paraphrases for the surviving statements.
393#[must_use]
394pub fn summarize(statements: &[Statement], config: &SummarizationConfig) -> Vec<Statement> {
395 if statements.is_empty() {
396 return Vec::new();
397 }
398 let mut filtered: Vec<Statement> = statements
399 .iter()
400 .filter(|s| !(config.drop_boilerplate && s.kind.is_boilerplate()))
401 .cloned()
402 .collect();
403 filtered.sort_by_key(|stmt| core::cmp::Reverse(stmt.weight));
404 let cap = config.effective_max_statements(filtered.len());
405 filtered.truncate(cap);
406
407 if config.mode == SummarizationMode::Expand {
408 // Double the surviving set with NSM paraphrases so the result lands
409 // near the requested ~200% target ratio.
410 let mut expanded: Vec<Statement> = Vec::with_capacity(filtered.len() * 2);
411 for stmt in &filtered {
412 expanded.push(stmt.clone());
413 if config.use_semantic_primes {
414 let mut paraphrase = stmt.clone();
415 paraphrase.text = apply_semantic_primes(&stmt.text, &config.language);
416 paraphrase.weight = stmt.weight.saturating_sub(5);
417 if paraphrase.text != stmt.text {
418 expanded.push(paraphrase);
419 }
420 }
421 }
422 return expanded;
423 }
424
425 if config.use_compound_words {
426 for stmt in &mut filtered {
427 stmt.text = apply_compound_words(&stmt.text, &config.language);
428 }
429 }
430
431 filtered
432}
433
434/// Render a slice of statements as a single block of text. Statements are
435/// joined with single spaces (after re-punctuation) so the result reads as
436/// continuous prose.
437#[must_use]
438pub fn deformalize(statements: &[Statement]) -> String {
439 statements
440 .iter()
441 .map(|s| {
442 let trimmed = s.text.trim();
443 if trimmed.is_empty() {
444 String::new()
445 } else if ends_with_terminal_punct(trimmed) {
446 trimmed.to_string()
447 } else {
448 format!("{trimmed}.")
449 }
450 })
451 .filter(|s| !s.is_empty())
452 .collect::<Vec<_>>()
453 .join(" ")
454}
455
456fn ends_with_terminal_punct(text: &str) -> bool {
457 text.chars()
458 .last()
459 .is_some_and(|c| matches!(c, '.' | '!' | '?' | '。' | '…' | '।' | '॥' | '」' | '"'))
460}
461
462/// Render the topic label (1–5 words) for the supplied statements.
463///
464/// When `explicit_topic` is non-empty (e.g. `project.topic`) it is returned
465/// verbatim. Otherwise the first content noun of the highest-weight
466/// statement is used.
467#[must_use]
468pub fn to_topic(explicit_topic: &str, statements: &[Statement]) -> String {
469 let candidate = explicit_topic.trim();
470 if !candidate.is_empty() {
471 return clamp_words(candidate, 5);
472 }
473 statements
474 .iter()
475 .max_by_key(|s| s.weight)
476 .map(|s| clamp_words(&s.text, 5))
477 .unwrap_or_default()
478}
479
480fn clamp_words(text: &str, max_words: usize) -> String {
481 text.split_whitespace()
482 .take(max_words)
483 .collect::<Vec<_>>()
484 .join(" ")
485 .trim_end_matches(['.', ',', '!', '?', ';', ':', '…', '」', '"'])
486 .to_string()
487}
488
489/// Substitute a few common compound forms with shorter equivalents.
490/// Vocabulary is intentionally tiny; extending it is a single-line addition.
491#[must_use]
492pub fn apply_compound_words(text: &str, language: &str) -> String {
493 let pairs: &[(&str, &str)] = match language {
494 "ru" => &[
495 ("в которой ", "где "),
496 ("для того чтобы ", "чтобы "),
497 ("к примеру", "например"),
498 ],
499 _ => &[
500 ("in order to ", "to "),
501 ("for the purpose of ", "for "),
502 ("a number of ", "several "),
503 ("user interface", "UI"),
504 ("command line interface", "CLI"),
505 ("artificial intelligence", "AI"),
506 ],
507 };
508 let mut out = text.to_string();
509 for (long, short) in pairs {
510 out = out.replace(long, short);
511 }
512 out
513}
514
515/// Substitute compound or rare words with NSM semantic primes.
516///
517/// See <https://en.wikipedia.org/wiki/Natural_semantic_metalanguage>. This is a
518/// best-effort heuristic — the vocabulary is short and additive, so callers
519/// always see *some* simplification even when the prime is only an
520/// approximation.
521#[must_use]
522pub fn apply_semantic_primes(text: &str, language: &str) -> String {
523 let pairs: &[(&str, &str)] = match language {
524 "ru" => &[
525 ("автоматизация", "когда машина делает"),
526 ("оркестрирует", "управляет вместе"),
527 ("делегирование", "передача работы"),
528 ("детерминированный", "всегда одинаковый"),
529 ],
530 _ => &[
531 ("orchestrates", "controls many"),
532 (
533 "automation of automation",
534 "machine that makes other machines do",
535 ),
536 ("automation", "machine doing"),
537 ("delegating", "giving work to"),
538 ("deterministic", "always the same"),
539 ("multilingual", "in many languages"),
540 ("symbolic", "rule-based"),
541 ],
542 };
543 let mut out = text.to_string();
544 for (compound, prime) in pairs {
545 out = out.replace(compound, prime);
546 }
547 out
548}
549
550/// Build a description from the curated project record.
551///
552/// Centralizes the "look up project → pick statements for language →
553/// summarize → deformalize" pipeline so callers can request `Topic` / `Short`
554/// / `Standard` / `Full` / `Expand` length with one call.
555#[must_use]
556pub fn describe_project(project: &ProjectRecord, config: &SummarizationConfig) -> String {
557 let seed_statements = project.statements_for(&config.language);
558 let statements: Vec<Statement> = seed_statements.iter().map(Statement::from_seed).collect();
559 if config.mode == SummarizationMode::Topic {
560 return to_topic(project.topic_for(&config.language), &statements);
561 }
562 let summarized = summarize(&statements, config);
563 deformalize(&summarized)
564}
565
566mod dialog;
567mod file;
568mod markdown;
569mod resource;
570
571pub use dialog::{formalize_dialog, generate_chat_title, summarize_dialog, DialogTurn};
572pub use file::{
573 formalize_repository_file, summarize_repository_file, EmbeddedGrammarFormalization,
574 MetaLanguageFormalization, RepositoryFileFormalization,
575};
576pub use markdown::{describe_readme, formalize_markdown, strip_markdown_noise};
577pub use resource::{
578 formalize_repository_directory, formalize_repository_resource, summarize_repository_resource,
579 RepositoryDirectoryFormalization, RepositoryEntry, RepositoryResourceFormalization,
580};