From 5c600a5a034be2d2a11f0e8dc538e97468b74c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 14:23:35 +0000 Subject: [PATCH] feat(compilation): publish every right cut of chained compositions for ricochets A part that is itself a composed word can be cut in turn: etymologiste cuts as etymologie + iste, then etymologie as etymo + logie. The ricochets block now carries those other cuts as autres_coupes (ADR 0084), combining the cuts of each composed part and clipping an elided part to its segment. More than four cuts in all publish none, as the API accepts four. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BkYb6EamApcbLXBiXShSgV --- README.md | 2 +- outils/src/compilation.rs | 71 +++++++++++++++++++++++++++++++++ outils/src/tests/compilation.rs | 54 +++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0d754f42..10e5579f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ La page montre les notes internes et les faits non relus. Elle reste locale : `r | Fichier | Contenu | |---|---| | `r/.json` | Ressources : carte de chaque unité déclarée, recette, exclusions et trompe-l’œil d’un fascicule. Un trompe-l’œil publié n’emporte ni son fait, ni sa relecture, ni sa note : seulement la paire, le motif et les deux explications. | -| `f/.json` | Manifeste de fascicule : identifiant, compteurs hors légendaires, briques tirables, graines, jalons, chemins des cartes et de leurs silhouettes, silhouettes des langues citées (`langues`), jaquette, recettes, exclusions et `ricochets` (sens affiché et classe de sens des briques, classe du sens littéral et coupes des mots, sans aucun texte ; ni légendaire ni mot qui en dépend, [ADR 0044](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0044-ricochets-reviser-et-maitriser-les-cartes.md)), `trompes_l_oeil` (chemin de la ressource) et `homographes` (groupes d’identifiants de briques de même forme, hors légendaires, [ADR 0052](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0052-fusions-trompeuses-expliquees.md)). Ces deux clés n’existent que si elles ne sont pas vides : un fascicule sans fausse piste ni homographe garde son empreinte. | +| `f/.json` | Manifeste de fascicule : identifiant, compteurs hors légendaires, briques tirables, graines, jalons, `ricochets` (dont, pour un mot dont une partie est elle-même un mot composé, `autres_coupes` : les découpes que le joueur doit trouver en plus de la préférée, ADR 0084 ; au-delà de quatre découpes, aucune n'est publiée), chemins des cartes et de leurs silhouettes, silhouettes des langues citées (`langues`), jaquette, recettes, exclusions et `ricochets` (sens affiché et classe de sens des briques, classe du sens littéral et coupes des mots, sans aucun texte ; ni légendaire ni mot qui en dépend, [ADR 0044](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0044-ricochets-reviser-et-maitriser-les-cartes.md)), `trompes_l_oeil` (chemin de la ressource) et `homographes` (groupes d’identifiants de briques de même forme, hors légendaires, [ADR 0052](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0052-fusions-trompeuses-expliquees.md)). Ces deux clés n’existent que si elles ne sont pas vides : un fascicule sans fausse piste ni homographe garde son empreinte. | | `a/.svg` | Médias : silhouettes et jaquettes, nommées d’après l’empreinte de leurs octets, jamais d’après le mot ni l’identifiant (ADR 0025). | | `parutions.json` | Calendrier des parutions, seul fichier modifiable : version, référentiels, quêtes au long cours et cadeau de bienvenue (si `ludique/quetes.json` existe), et pour chaque fascicule sa date et son manifeste. La tâche de parution en tire `public/index.json`. | diff --git a/outils/src/compilation.rs b/outils/src/compilation.rs index f983b7a4..2cee1aaa 100644 --- a/outils/src/compilation.rs +++ b/outils/src/compilation.rs @@ -34,6 +34,9 @@ use crate::medias::{self, CleMedia}; pub const FORMAT: i64 = 1; +/// Nombre de découpes que l'API accepte pour un mot (`DECOUPES_MAX`, crates/api/src/ricochets.rs). +const DECOUPES_MAX: usize = 4; + /// Champs éditoriaux jamais publiés (modèle de données, carte de répartition). pub const CHAMPS_PRIVES: [&str; 2] = ["note_interne", "etat_relecture"]; @@ -410,6 +413,68 @@ impl<'a> Compilateur<'a> { .collect() } + /// Toutes les découpes justes d'un mot, la préférée d'abord, positions relatives à sa forme (ADR 0084). + /// + /// Une partie qui est elle-même un mot composé peut se couper à son tour : *étymologiste* se coupe + /// en *étymologie · iste*, puis *étymologie* en *étymo · logie*, soit `[10]` et `[5, 10]`. Chaque + /// partie compte pour une découpe indépendante, d'où toutes les combinaisons. Une partie élidée + /// (*biologie* dans *biologiste*) est bornée par son segment : une coupe au-delà n'existe pas dans + /// le mot. Vide : le mot ne se coupe pas. + fn decoupes(&self, mot_id: &str, vus: &[&str]) -> Vec> { + let Some(composition) = self.composition_preferee(mot_id) else { + return Vec::new(); + }; + let base = self.coupes(composition); + if base.is_empty() { + return Vec::new(); + } + let mut parties: Vec<&Value> = liste(&composition["parties"]).iter().collect(); + parties.sort_by_key(|p| p["rang"].as_i64()); + let vus: Vec<&str> = vus.iter().copied().chain([mot_id]).collect(); + let mut toutes = vec![base]; + for partie in parties { + let (Some(debut), Some(fin)) = ( + partie["segment_debut"].as_i64(), + partie["segment_fin"].as_i64(), + ) else { + continue; + }; + let unite = texte(&partie["unite_id"]); + if vus.contains(&unite) { + continue; + } + // Les façons de couper cette partie, ramenées dans le mot entier. + let mut options: Vec> = Vec::new(); + for relatives in self.decoupes(unite, &vus) { + let ramenees: Vec = relatives + .into_iter() + .filter(|c| *c < fin - debut) + .map(|c| c + debut) + .collect(); + if !ramenees.is_empty() && !options.contains(&ramenees) { + options.push(ramenees); + } + } + let mut suite = toutes.clone(); + for existante in &toutes { + for option in &options { + let mut fusion: Vec = existante.iter().chain(option).copied().collect(); + fusion.sort_unstable(); + fusion.dedup(); + if !suite.contains(&fusion) { + suite.push(fusion); + } + } + } + toutes = suite; + } + // La préférée d'abord, puis les autres par nombre de coupes croissant. + let base = toutes.remove(0); + toutes.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b))); + toutes.insert(0, base); + toutes + } + /// Les deux briques de la composition préférée, préfixe puis suffixe, quand le mot s'assemble /// (ADR 0067) ; vide sinon. Les deux briques doivent figurer parmi celles des ricochets. fn assemblage(&self, mot_id: &str, briques: &[Value]) -> Vec { @@ -465,6 +530,12 @@ impl<'a> Compilateur<'a> { .unwrap_or_default(); let assemblage = self.assemblage(uid, &briques); let mut mot = json!({"id": uid, "coupes": coupes}); + // Les autres découpes justes (ADR 0084). Au-delà de ce que l'API accepte, le joueur ne pourrait + // pas deviner lesquelles poser : on n'en publie aucune plutôt qu'une partie. + let decoupes = self.decoupes(uid, &[]); + if (2..=DECOUPES_MAX).contains(&decoupes.len()) { + mot["autres_coupes"] = json!(&decoupes[1..]); + } if !assemblage.is_empty() { mot["assemblage"] = json!(assemblage); } diff --git a/outils/src/tests/compilation.rs b/outils/src/tests/compilation.rs index 08a30613..ae5f17bf 100644 --- a/outils/src/tests/compilation.rs +++ b/outils/src/tests/compilation.rs @@ -626,6 +626,60 @@ fn manifeste_porte_les_donnees_de_jugement_des_ricochets() { } } +// ------------------------------------------------------- découpes en chaîne (ADR 0084) + +/// Le mot publié dans les ricochets du manifeste, par sa graphie. +fn mot_de_ricochet(manifeste: &Value, depot: &Depot, graphie: &str) -> Option { + let uid = depot.uid(graphie); + liste(&manifeste["ricochets"]["mots"]) + .iter() + .find(|m| texte(&m["id"]) == uid) + .cloned() +} + +#[test] +fn une_composition_chainee_publie_ses_autres_decoupes() { + let depot = Depot::new(); + let sortie = TempDir::new().unwrap(); + let parutions = compiler_vers(&depot, sortie.path(), true); + let manifeste = manifeste(sortie.path(), &liste(&parutions["fascicules"])[0]); + // biologiste = biologie · iste, et biologie = bio · logie. + let mot = mot_de_ricochet(&manifeste, &depot, "biologiste").expect("biologiste est publié"); + assert_eq!(mot["coupes"], json!([6])); + assert_eq!(mot["autres_coupes"], json!([[3, 6]])); +} + +#[test] +fn un_mot_sans_composition_chainee_n_a_pas_d_autres_decoupes() { + let depot = Depot::new(); + let sortie = TempDir::new().unwrap(); + let parutions = compiler_vers(&depot, sortie.path(), true); + let manifeste = manifeste(sortie.path(), &liste(&parutions["fascicules"])[0]); + let mot = mot_de_ricochet(&manifeste, &depot, "géologie").expect("géologie est publié"); + assert_eq!(mot["coupes"], json!([3])); + assert!(mot.get("autres_coupes").is_none()); +} + +#[test] +fn une_coupe_au_dela_de_la_partie_elidee_n_existe_pas() { + // Si la partie « biologie » ne couvre plus que [0, 2) de « biologiste », la coupe de « biologie » + // à 3 tombe hors du mot : il ne reste que la découpe de premier niveau. + let depot = Depot::new(); + let ecrire = |depot: &Depot| { + depot.modifier(depot.composition("biologiste"), |c| { + c["parties"][0]["segment_fin"] = json!(2); + c["parties"][1]["segment_debut"] = json!(2); + }); + }; + ecrire(&depot); + let sortie = TempDir::new().unwrap(); + let parutions = compiler_vers(&depot, sortie.path(), true); + let manifeste = manifeste(sortie.path(), &liste(&parutions["fascicules"])[0]); + let mot = mot_de_ricochet(&manifeste, &depot, "biologiste").expect("biologiste est publié"); + assert_eq!(mot["coupes"], json!([2])); + assert!(mot.get("autres_coupes").is_none()); +} + // ------------------------------------------------------------------ trompe-l'œil (ADR 0052) /// Compile l'essai après avoir préparé le dépôt ; renvoie le manifeste du fascicule d'essai.