Fusion de main : conversation Rust par défaut, télémétrie du tour

main a reçu en parallèle la télémétrie du tour tenu par la façade
(/internal/v1/turn/perf), les tickets « en vol » du moteur et la
conversation tenue par la façade par défaut (LOKI_CHAT=go pour revenir
au Go). Le lot 29 faisait la même chose par /internal/v1/perf/record :
un seul chemin est gardé, celui de main.

- TurnHost::perf_record (main) remplace TurnHost::perf ; la nature est
  posée par l'hôte (verify pour la passe isolée seulement, main pour les
  corrections du builder, comme le Go).
- Les résumés de compaction passent par le même point d'entrée, nature
  compact : chat/perf.rs ne garde que le relevé de la réponse entière
  (perfRecFromWire) et l'envoi.
- La sonde du gabarit reste à la façade (lot 23) : /internal/v1/turn/perf
  ne la lance plus côté Go.
- facade_perf.go retiré ; ses cas (perte, intercalé, alerte, preset
  externe, incomplète, GET) rejoignent web_internal_turnperf_test.go.
- README : LOKI_CHAT défaut Rust, avec ce que les lots 19, 23, 29 et 30
  y ajoutent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 01:53:53 +00:00
commit 89ed527c96
20 files changed
+713 -457

No files matched your search

+111 -210
View File
@@ -1,16 +1,18 @@
//! Télémétrie des complétions de la façade (lot 29) — port de la partie
//! « relevé » de `perf_log.go` : `perfRecFromStats`, `perfRecFromWire`,
//! `perfPrefix`. L'anneau, le calcul de la perte de cache et son seuil
//! d'alerte restent au Go (`/internal/v1/perf/record`) : les complétions de
//! la façade rejoignent celles du Go dans `/api/perf/summary`.
//! Télémétrie des complétions de la façade (lot 29). Toutes rejoignent
//! l'anneau du Go (`perf_log.go`) par `/internal/v1/turn/perf` : le Go les
//! range, calcule la perte de cache contre la précédente de la même
//! discussion et l'alerte au seuil du preset actif. `/api/perf/summary`
//! reste au Go.
//!
//! Des identifiants et des compteurs, jamais de texte. Rien ne retourne au
//! Ce module porte ce que la façade relève elle-même : la réponse entière
//! d'un résumé de compaction (`perfRecFromWire`), et l'envoi au Go. Des
//! identifiants et des compteurs, jamais de texte. Rien ne retourne au
//! moteur.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::types::{Message, StatsEvent};
use super::turn::{PerfMarks, PerfRec};
use super::types::StatsEvent;
use crate::store::Store;
/// Nature d'une complétion.
@@ -18,114 +20,47 @@ pub const KIND_MAIN: &str = "main";
pub const KIND_VERIFY: &str = "verify";
pub const KIND_COMPACT: &str = "compact";
fn is_zero_i(v: &i64) -> bool {
*v == 0
}
fn is_zero_f(v: &f64) -> bool {
*v == 0.0
}
/// `perfRec` (sans `at` ni `seq`, posés par le Go).
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct PerfRec {
pub kind: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub conv: String,
pub iter: i64,
pub complete: bool,
/// usage.prompt_tokens : la taille du prompt entier.
#[serde(skip_serializing_if = "is_zero_i")]
pub total: i64,
/// cache_n ; `None` : inconnu.
#[serde(skip_serializing_if = "Option::is_none")]
pub cached: Option<i64>,
/// prompt_n : les jetons réellement recalculés.
#[serde(skip_serializing_if = "is_zero_i")]
pub new: i64,
#[serde(skip_serializing_if = "is_zero_f")]
pub pp_ms: f64,
#[serde(skip_serializing_if = "is_zero_f")]
pub pp_tps: f64,
#[serde(rename = "gen", skip_serializing_if = "is_zero_i")]
pub generated: i64,
#[serde(skip_serializing_if = "is_zero_f")]
pub tg_tps: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_n: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_accepted: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttft_ms: Option<i64>,
}
impl PerfRec {
/// `perfRecFromStats` : la complétion telle que la boucle l'a vue.
pub fn from_stats(kind: &str, conv: &str, iter: i64, complete: bool, s: &StatsEvent) -> Self {
PerfRec {
kind: kind.into(),
conv: conv.into(),
iter,
complete,
total: s.prompt_tokens_total,
cached: s.cache_tokens,
new: s.prompt_tokens,
pp_ms: s.prompt_ms,
pp_tps: s.prompt_per_second,
generated: s.gen_tokens,
tg_tps: s.gen_per_second,
draft_n: s.draft_n,
draft_accepted: s.draft_accepted,
ttft_ms: s.ttft_ms,
/// `perfRecFromWire` : une réponse NON streamée (compaction), lue d'un
/// bloc, au format des stats d'une complétion streamée. Jamais d'échec : un
/// compteur illisible reste inconnu. Rend aussi si la complétion est
/// complète (un usage, ou des timings).
pub fn wire_stats(raw: &[u8]) -> (bool, StatsEvent) {
let mut s = StatsEvent::default();
// Seule la première valeur JSON compte (`json.Decoder`).
let v = serde_json::Deserializer::from_slice(raw)
.into_iter::<Value>()
.next()
.and_then(Result::ok)
.unwrap_or(Value::Null);
// Une valeur non nulle d'un autre type (chaîne, nombre) alloue quand
// même la structure côté Go avant l'erreur de type : présente, vide.
let has_timings = v.get("timings").is_some_and(|t| !t.is_null());
if let Some(t) = v.get("timings").filter(|t| t.is_object()) {
let n = |k: &str| perf_num(t.get(k));
s.prompt_tokens = n("prompt_n").unwrap_or(0.0) as i64;
s.prompt_ms = n("prompt_ms").unwrap_or(0.0);
s.prompt_per_second = n("prompt_per_second").unwrap_or(0.0);
s.gen_tokens = n("predicted_n").unwrap_or(0.0) as i64;
s.gen_per_second = n("predicted_per_second").unwrap_or(0.0);
s.cache_tokens = n("cache_n").map(|f| f as i64);
s.draft_n = n("draft_n").map(|f| f as i64);
s.draft_accepted = n("draft_n_accepted").map(|f| f as i64);
}
let usage = v.get("usage").filter(|u| u.is_object());
if let Some(u) = usage {
s.prompt_tokens_total = perf_num(u.get("prompt_tokens")).unwrap_or(0.0) as i64;
if s.gen_tokens == 0 {
s.gen_tokens = perf_num(u.get("completion_tokens")).unwrap_or(0.0) as i64;
}
}
/// `perfRecFromWire` : une réponse NON streamée (compaction), lue d'un
/// bloc. Jamais d'échec : un compteur illisible reste inconnu.
pub fn from_wire(kind: &str, conv: &str, raw: &[u8]) -> Self {
let mut r = PerfRec {
kind: kind.into(),
conv: conv.into(),
..Default::default()
};
// Seule la première valeur JSON compte (`json.Decoder`).
let v = serde_json::Deserializer::from_slice(raw)
.into_iter::<Value>()
.next()
.and_then(Result::ok)
.unwrap_or(Value::Null);
// Une valeur non nulle d'un autre type (chaîne, nombre) alloue quand
// même la structure côté Go avant l'erreur de type : présente, vide.
let has_timings = v.get("timings").is_some_and(|t| !t.is_null());
let timings = v.get("timings").filter(|t| t.is_object());
if let Some(t) = timings {
let n = |k: &str| perf_num(t.get(k));
r.new = n("prompt_n").unwrap_or(0.0) as i64;
r.pp_ms = n("prompt_ms").unwrap_or(0.0);
r.pp_tps = n("prompt_per_second").unwrap_or(0.0);
r.generated = n("predicted_n").unwrap_or(0.0) as i64;
r.tg_tps = n("predicted_per_second").unwrap_or(0.0);
r.cached = n("cache_n").map(|f| f as i64);
r.draft_n = n("draft_n").map(|f| f as i64);
r.draft_accepted = n("draft_n_accepted").map(|f| f as i64);
}
let usage = v.get("usage").filter(|u| u.is_object());
if let Some(u) = usage {
r.total = perf_num(u.get("prompt_tokens")).unwrap_or(0.0) as i64;
if r.generated == 0 {
r.generated = perf_num(u.get("completion_tokens")).unwrap_or(0.0) as i64;
}
}
if r.cached.is_none() {
r.cached = usage
.and_then(|u| u.get("prompt_tokens_details"))
.filter(|d| d.is_object())
.and_then(|d| perf_num(d.get("cached_tokens")))
.map(|f| f as i64);
}
r.complete = r.total > 0 || has_timings;
r
if s.cache_tokens.is_none() {
s.cache_tokens = usage
.and_then(|u| u.get("prompt_tokens_details"))
.filter(|d| d.is_object())
.and_then(|d| perf_num(d.get("cached_tokens")))
.map(|f| f as i64);
}
(s.prompt_tokens_total > 0 || has_timings, s)
}
/// `perfNum` : un nombre (ou une chaîne qui en contient un), fini, positif
@@ -139,104 +74,82 @@ fn perf_num(v: Option<&Value>) -> Option<f64> {
(f.is_finite() && (0.0..1e12).contains(&f)).then_some(f)
}
/// FNV-1a 64 bits.
fn fnv1a(h: &mut u64, b: &[u8]) {
for &c in b {
*h ^= u64::from(c);
*h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
/// `perfPrefix` : empreintes cumulées des messages envoyés (l'empreinte i
/// couvre les messages 0..i). Une requête PROLONGE la précédente de la même
/// discussion quand l'empreinte de la précédente est un de ses préfixes.
pub fn prefix(msgs: &[Message]) -> Vec<u64> {
let mut h = 0xcbf2_9ce4_8422_2325u64;
msgs.iter()
.map(|m| {
fnv1a(&mut h, &serde_json::to_vec(m).unwrap_or_default());
fnv1a(&mut h, &[0]);
h
})
.collect()
}
/// Ce que le Go a tiré de la complétion rangée.
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
pub struct PerfNoted {
#[serde(default)]
pub lost: Option<i64>,
#[serde(default)]
pub lost_after: String,
#[serde(default)]
pub alert: bool,
}
/// Range la complétion dans l'anneau du Go. Le Go injoignable : rien (la
/// télémétrie ne fait jamais échouer un tour).
pub async fn record(store: &Store, rec: &PerfRec, prefix: &[u64], external: bool) -> PerfNoted {
/// Range la complétion dans l'anneau du Go et rend ce qu'il en tire. Le Go
/// injoignable : rien (la télémétrie ne fait jamais échouer un tour).
pub async fn record(store: &Store, kind: &str, conv: &str, rec: &PerfRec) -> PerfMarks {
let body = json!({
"kind": kind,
"conv": conv,
"iter": rec.iter,
"complete": rec.complete,
"external": rec.external,
"stats": rec.stats,
"prefix": rec.prefix,
});
match store
.internal_post(
"/internal/v1/perf/record",
&json!({ "rec": rec, "prefix": prefix, "external": external }),
)
.internal_json(hyper::Method::POST, "/internal/v1/turn/perf", Some(&body))
.await
{
Ok((code, v)) if code.is_success() => serde_json::from_value(v).unwrap_or_default(),
_ => PerfNoted::default(),
Ok(v) => PerfMarks {
kind: v["kind"].as_str().unwrap_or("").to_string(),
lost: v["lost"].as_i64(),
lost_after: v["lost_after"].as_str().unwrap_or("").to_string(),
lost_alert: v["lost_alert"].as_bool().unwrap_or(false),
},
Err(e) => {
eprintln!("[loki-rs] télémétrie : {e}");
PerfMarks::default()
}
}
}
#[cfg(test)]
mod tests {
use super::super::turn::perf_prefix;
use super::super::types::Message;
use super::*;
#[test]
fn releve_d_une_reponse_entiere() {
let r = PerfRec::from_wire(
KIND_COMPACT,
"c1",
let (complete, s) = wire_stats(
br#"{"timings":{"prompt_n":"120","prompt_ms":50.5,"prompt_per_second":2376,"predicted_n":30,"predicted_per_second":40,"cache_n":900,"draft_n":-1},"usage":{"prompt_tokens":1020,"completion_tokens":31}} parasite"#,
);
assert!(complete);
assert_eq!(
r,
PerfRec {
kind: "compact".into(),
conv: "c1".into(),
complete: true,
total: 1020,
cached: Some(900),
new: 120,
pp_ms: 50.5,
pp_tps: 2376.0,
generated: 30,
tg_tps: 40.0,
s,
StatsEvent {
prompt_tokens_total: 1020,
cache_tokens: Some(900),
prompt_tokens: 120,
prompt_ms: 50.5,
prompt_per_second: 2376.0,
gen_tokens: 30,
gen_per_second: 40.0,
..Default::default()
}
);
// Sans timings : le cache d'OpenAI, les jetons générés de l'usage.
let r = PerfRec::from_wire(
KIND_COMPACT,
"",
let (complete, s) = wire_stats(
br#"{"usage":{"prompt_tokens":10,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}"#,
);
assert_eq!(
(r.complete, r.total, r.generated, r.cached),
(
complete,
s.prompt_tokens_total,
s.gen_tokens,
s.cache_tokens
),
(true, 10, 3, Some(4))
);
// Rien de lisible : incomplète, tout inconnu.
let r = PerfRec::from_wire(KIND_COMPACT, "", b"pas du json");
assert!(!r.complete);
assert_eq!(r.cached, None);
let r = PerfRec::from_wire(
KIND_COMPACT,
"",
br#"{"timings":null,"usage":{"prompt_tokens":1e13}}"#,
);
assert_eq!((r.complete, r.total), (false, 0));
let (complete, s) = wire_stats(b"pas du json");
assert!(!complete);
assert_eq!(s.cache_tokens, None);
let (complete, s) = wire_stats(br#"{"timings":null,"usage":{"prompt_tokens":1e13}}"#);
assert_eq!((complete, s.prompt_tokens_total), (false, 0));
// Des timings d'un autre type : présents mais vides, comme le Go.
let r = PerfRec::from_wire(KIND_COMPACT, "", br#"{"timings":"x"}"#);
assert_eq!((r.complete, r.new), (true, 0));
let (complete, s) = wire_stats(br#"{"timings":"x"}"#);
assert_eq!((complete, s.prompt_tokens), (true, 0));
}
#[test]
@@ -246,34 +159,22 @@ mod tests {
content: Some(json!(c)),
..Default::default()
};
let a = prefix(&[m("a"), m("b")]);
let b = prefix(&[m("a"), m("b"), m("c")]);
let a = perf_prefix(&[m("a"), m("b")]);
let b = perf_prefix(&[m("a"), m("b"), m("c")]);
assert_eq!(a.len(), 2);
assert_eq!(b[..2], a[..]);
assert_ne!(prefix(&[m("x"), m("b")])[1], a[1]);
assert!(prefix(&[]).is_empty());
// FNV-1a de référence : « a » seul.
assert_ne!(perf_prefix(&[m("x"), m("b")])[1], a[1]);
assert!(perf_prefix(&[]).is_empty());
// FNV-1a de référence : le JSON du message, puis l'octet nul.
let mut h = 0xcbf2_9ce4_8422_2325u64;
fnv1a(&mut h, b"a");
for c in serde_json::to_vec(&m("a")).unwrap().into_iter().chain([0]) {
h = (h ^ u64::from(c)).wrapping_mul(0x0000_0100_0000_01b3);
}
assert_eq!(perf_prefix(&[m("a")]), vec![h]);
let mut h = 0xcbf2_9ce4_8422_2325u64;
for c in *b"a" {
h = (h ^ u64::from(c)).wrapping_mul(0x0000_0100_0000_01b3);
}
assert_eq!(h, 0xaf63_dc4c_8601_ec8c);
}
#[test]
fn releve_au_format_du_go() {
let s = StatsEvent {
prompt_tokens: 5,
prompt_tokens_total: 100,
cache_tokens: Some(95),
gen_tokens: 7,
gen_per_second: 20.5,
ttft_ms: Some(30),
..Default::default()
};
let r = PerfRec::from_stats(KIND_MAIN, "c", 2, true, &s);
assert_eq!(
serde_json::to_value(&r).unwrap(),
json!({ "kind": "main", "conv": "c", "iter": 2, "complete": true, "total": 100,
"cached": 95, "new": 5, "gen": 7, "tg_tps": 20.5, "ttft_ms": 30 })
);
}
}