Patrick Schurig 483bbcb027 feat(traces): traces de décision NDJSON, une ligne par cycle (brief 4 T6, annexe D)
Réglage optimizerTrace (energy.conf Optimizer/trace, …RetentionDays, …MaxBytesPerDay), défaut
DÉSACTIVÉ : ni objet, ni fil, ni fichier. NymeaEnergy.GetOptimizerTrace / SetOptimizerTrace,
PermissionScopeAdmin comme SetOptimizerEndpoint, type contrôlé par le moteur, appliqué à chaud.

Une ligne par cycle — rule-based, optimiseur, et L2 (strategy none, sans requête) : t (heure
locale), request (params GetPlan envoyés, ou qui l'auraient été), response (ce qui a répondu
à CETTE requête : result brut, error.code, no-response ; sinon le plan du rule-based en codes),
executed (appliqué, clamped, shed), strategy, latencyMs. La ligne N attend la réponse à la
requête N (elle sert au cycle N+1).

Toutes les E/S sur un fil dédié (OptimizerTraceWriter) : rotation à minuit heure locale, purge
au-delà de la rétention (14 j), plafond par fichier journalier (64 Mio, mesuré : 5 Ko/ligne
rule-based, 39 Ko sous un optimiseur à 48 créneaux, 1 cycle/min), file bornée à 256 lignes.
Échec d'écriture et plafond dits une fois. update() ne fait que poster. Le token n'est jamais
dans une ligne ; recopié par une réponse d'optimiseur, il est masqué.

Preuves : decisions IDENTIQUE traces désactivées ET activées (21 132 lignes de trace écrites,
ETM_DECISIONS_TRACES=1). socketscheduler 33 fonctions, 114/0 (+10 fonctions T6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:12:25 +02:00

112 lines
5.5 KiB
Bash
Executable File

#!/bin/bash
# Décisions de référence (brief 4 T0.5) — rejoue les scénarios de `simulation` et compare, cycle
# par cycle, ce que l'arbitre a décidé au fichier de référence commité.
#
# run-decisions.sh <build> record <sortie.ndjson> enregistre seulement
# run-decisions.sh <build> check enregistre puis compare à la référence
# (code 1 au premier écart, et il est montré)
#
# ETM_DECISIONS_TRACES=1 (brief 4 T6) : mêmes scénarios, TRACES DE DÉCISION ACTIVÉES. La référence
# ne change pas — c'est la preuve que la trace n'influe sur aucune décision ; le total des lignes
# de trace écrites est annoncé à la fin (il doit être non nul).
#
# <build> : arbre construit depuis le .pro racine avec CONFIG+=build_tests (le binaire
# `nymea-energy-decisions` y est sous tests/auto/decisions/).
#
# AGENTS.md : UN PROCESSUS PAR TEST, chacun sous MemoryMax=2G. Les tests ne tournent jamais en
# parallèle (le harnais nymea ouvre des ports fixes). Un test qui sort en erreur est rejoué UNE
# fois — instabilité connue du port 4847 — et le rejeu est annoncé, jamais masqué.
#
# RÉSEAU PRIVÉ PAR PROCESSUS (`--unshare-net`). Les ports fixes du harnais sont la seule chose
# qu'un /tmp privé n'isole pas : depuis le 2026-10-16, deux agents testent en parallèle sur la
# même machine, et un 4847 tenu par l'un faisait échouer l'autre. Dans son propre espace réseau,
# chaque processus a sa boucle locale et ses ports — aucun test n'a besoin d'un autre réseau.
set -u
ICI="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REFERENCE="$ICI/reference/decisions.ndjson.xz"
BUILD="$(cd "${1:?build}" && pwd)"
MODE="${2:?record|check}"
BIN_DIR="$BUILD/tests/auto/decisions"
BIN="$BIN_DIR/nymea-energy-decisions"
[ -x "$BIN" ] || { echo "binaire absent : $BIN" >&2; exit 2; }
case "$MODE" in
record) SORTIE="${3:?sortie}" ;;
check) SORTIE="$(mktemp --suffix=.ndjson)" ;;
*) echo "mode inconnu : $MODE" >&2; exit 2 ;;
esac
TRAVAIL="$(mktemp -d)"
trap 'rm -rf "$TRAVAIL"' EXIT
# L'HORLOGE MURALE de chaque processus part de cet instant (voir decisionrecorder.cpp) : des
# scénarios datent leurs entrées sur l'heure courante, et la référence doit valoir à toute heure.
# 2026-10-16T05:00:00Z, début d'heure : un test qui arrondit à l'heure a 60 min de marge.
EPOQUE=1792126800
export NYMEA_PLUGINS_PATH="$BUILD/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins"
# UN /tmp PRIVÉ ET VIDE PAR PROCESSUS. libnymea range l'état du serveur de test sous
# `/tmp/nymea-test`, en dur : configuration des charges, energy.conf, magasin de prévision. Ce
# répertoire est partagé par toutes les exécutions de la machine et il s'accumule — mesuré le
# 2026-10-16, un energy-load-configuration.json de 348 bornes orphelines laissé là le 14. Sans
# isolation, le résultat dépendrait de l'historique de la machine, pas du code.
lancer() { # $1 = fonction, $2 = fichier NDJSON du test
rm -f "$2"
local trace=""
[ "${ETM_DECISIONS_TRACES:-0}" = 1 ] && trace="$TRAVAIL/$1.trace"
( cd "$BIN_DIR" && ETM_DECISIONS_OUT="$2" ETM_DECISIONS_EPOCH="$EPOQUE" ETM_DECISIONS_TRACE="$trace" \
systemd-run --user --scope -q -p MemoryMax=2G -p MemorySwapMax=0 \
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$BUILD" "$BUILD" --bind "$TRAVAIL" "$TRAVAIL" \
--chdir "$BIN_DIR" \
./target_wrapper.sh ./nymea-energy-decisions "$1" > "$TRAVAIL/$1.log" 2>&1 )
}
mapfile -t FONCTIONS < <(cd "$BIN_DIR" && ./nymea-energy-decisions -functions 2>/dev/null \
| sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)()$/\1/p')
[ "${#FONCTIONS[@]}" -gt 0 ] || { echo "aucune fonction listée" >&2; exit 2; }
: > "$SORTIE"
total=0
for f in "${FONCTIONS[@]}"; do
out="$TRAVAIL/$f.ndjson"
lancer "$f" "$out"; code=$?
if [ "$code" -ne 0 ]; then
echo "REJEU $f (code $code) — $(grep -m1 -E 'FAIL!' "$TRAVAIL/$f.log" | cut -c1-160)" >&2
lancer "$f" "$out"; code=$?
fi
n=0; [ -f "$out" ] && { n=$(wc -l < "$out"); cat "$out" >> "$SORTIE"; }
total=$((total + n))
resume=$(grep -m1 '^Totals:' "$TRAVAIL/$f.log")
printf '{"fn":"%s","fin":true,"exit":%d,"cycles":%d}\n' "$f" "$code" "$n" >> "$SORTIE"
printf '%-48s exit=%d cycles=%-6d %s\n' "$f" "$code" "$n" "$resume" >&2
done
# Le contrôle ne doit pas pouvoir disparaître : un enregistreur débranché (wrap perdu, binaire
# reconstruit sans lui) produirait un fichier sans cycle, que la comparaison verrait comme un
# écart massif — mais on le dit ici en clair.
[ "$total" -gt 0 ] || { echo "AUCUN cycle enregistré — l'enregistreur n'est pas branché" >&2; exit 2; }
echo "fonctions=${#FONCTIONS[@]} cycles=$total" >&2
if [ "${ETM_DECISIONS_TRACES:-0}" = 1 ]; then
read -r tl to < <(cat "$TRAVAIL"/*.trace 2>/dev/null | awk '{l+=$1; o+=$2} END {print l+0, o+0}')
echo "traces activées : $tl ligne(s) de trace, $to octet(s) écrits" >&2
[ "$tl" -gt 0 ] || { echo "AUCUNE ligne de trace — les traces ne sont pas branchées" >&2; exit 2; }
fi
[ "$MODE" = record ] && exit 0
ATTENDU="$TRAVAIL/attendu.ndjson"
xz -dc "$REFERENCE" > "$ATTENDU" || exit 2
if cmp -s "$ATTENDU" "$SORTIE"; then
echo "IDENTIQUE à la référence ($(wc -l < "$ATTENDU") lignes)" >&2
rm -f "$SORTIE"
exit 0
fi
echo "ÉCART avec la référence — premières lignes divergentes (- attendu, + obtenu) :" >&2
diff "$ATTENDU" "$SORTIE" | head -c 6000 >&2
echo >&2
echo "fichier obtenu conservé : $SORTIE" >&2
exit 1