From a5dc880a779f1559a9c28182543f39c3ab34056d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 06:24:33 +0000 Subject: [PATCH] feat(compilation): publish every cut of a chained word as one answer Replace autres_coupes (one answer per combination of refinements) with a single complete cut: etymologiste is cut etymo | logie | iste in one go, and only that is right (ADR 0084). coupes now holds the cuts of every composed part, an elided part clipped to its segment; beyond eight cuts, only the first-level boundaries are published. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BkYb6EamApcbLXBiXShSgV --- README.md | 2 +- outils/src/compilation.rs | 79 ++++++++++++--------------------- outils/src/tests/compilation.rs | 49 ++++++++++---------- 3 files changed, 54 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 10e5579f..c1901b33 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, `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. | +| `f/.json` | Manifeste de fascicule : identifiant, compteurs hors légendaires, briques tirables, graines, jalons, `ricochets` (dont, pour chaque mot, `coupes` : toutes ses coupes, celles des parties qui sont elles-mêmes des mots composés comprises, que le joueur pose d'un coup, ADR 0084 ; au-delà de huit coupes, les seules frontières du premier niveau), 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 2cee1aaa..90170f99 100644 --- a/outils/src/compilation.rs +++ b/outils/src/compilation.rs @@ -34,8 +34,8 @@ 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; +/// Nombre de coupes que l'API accepte dans une réponse (`COUPES_MAX`, crates/api/src/ricochets.rs). +const COUPES_MAX: usize = 8; /// Champs éditoriaux jamais publiés (modèle de données, carte de répartition). pub const CHAMPS_PRIVES: [&str; 2] = ["note_interne", "etat_relecture"]; @@ -413,25 +413,24 @@ 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). + /// Toutes les coupes d'un mot, chaînage compris, 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> { + /// Une partie qui est elle-même un mot composé se coupe à son tour : *étymologiste* est + /// *étymologie · iste*, et *étymologie* est *étymo · logie*, d'où les coupes `[5, 10]`. Le joueur les + /// pose toutes d'un coup, et seule cette coupe complète est juste. 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 coupes_completes(&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 coupes = self.coupes(composition); + if coupes.is_empty() { + return coupes; } 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(), @@ -443,36 +442,16 @@ impl<'a> Compilateur<'a> { 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 + coupes.extend( + self.coupes_completes(unite, &vus) .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; + .map(|c| c + debut), + ); } - // 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 + coupes.sort_unstable(); + coupes.dedup(); + coupes } /// Les deux briques de la composition préférée, préfixe puis suffixe, quand le mot s'assemble @@ -524,18 +503,18 @@ impl<'a> Compilateur<'a> { if !resultats.contains(uid) || self.c.unites[uid]["nature"] != "mot" { continue; } - let coupes = self - .composition_preferee(uid) - .map(|c| self.coupes(c)) - .unwrap_or_default(); + // Toutes les coupes du mot, parties composées comprises (ADR 0084). Au-delà de ce que l'API + // accepte pour une réponse, seules les frontières du premier niveau sont publiées. + let complete = self.coupes_completes(uid, &[]); + let coupes = if complete.len() > COUPES_MAX { + self.composition_preferee(uid) + .map(|c| self.coupes(c)) + .unwrap_or_default() + } else { + complete + }; 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 ae5f17bf..cea61e8d 100644 --- a/outils/src/tests/compilation.rs +++ b/outils/src/tests/compilation.rs @@ -626,7 +626,7 @@ fn manifeste_porte_les_donnees_de_jugement_des_ricochets() { } } -// ------------------------------------------------------- découpes en chaîne (ADR 0084) +// ------------------------------------------------------- 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 { @@ -637,47 +637,46 @@ fn mot_de_ricochet(manifeste: &Value, depot: &Depot, graphie: &str) -> Option (Value, TempDir) { let sortie = TempDir::new().unwrap(); - let parutions = compiler_vers(&depot, sortie.path(), true); + let parutions = compiler_vers(depot, sortie.path(), true); let manifeste = manifeste(sortie.path(), &liste(&parutions["fascicules"])[0]); - // biologiste = biologie · iste, et biologie = bio · logie. + (manifeste, sortie) +} + +#[test] +fn une_composition_chainee_publie_toutes_ses_coupes() { + let depot = Depot::new(); + let (manifeste, _sortie) = manifeste_du_depot(&depot); + // biologiste = biologie · iste, et biologie = bio · logie : le joueur pose les deux coupes d'un coup. 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]])); + assert_eq!(mot["coupes"], json!([3, 6])); + assert!( + mot.get("autres_coupes").is_none(), + "une seule coupe est juste" + ); } #[test] -fn un_mot_sans_composition_chainee_n_a_pas_d_autres_decoupes() { +fn un_mot_sans_composition_chainee_garde_sa_coupe() { 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 (manifeste, _sortie) = manifeste_du_depot(&depot); 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. + // à 3 tombe hors du mot : il ne reste que la frontière 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]); + depot.modifier(depot.composition("biologiste"), |c| { + c["parties"][0]["segment_fin"] = json!(2); + c["parties"][1]["segment_debut"] = json!(2); + }); + let (manifeste, _sortie) = manifeste_du_depot(&depot); 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)