#!/usr/bin/env catnip
# Profiler du Catnip qui appelle du Python. cProfile voit tout le côté
# Python de la frontière -- fonctions Python et builtins, y compris quand
# l'appel part de Catnip -- et rien du code Catnip lui-même. Le profil
# reste exploitable, puisque le temps part le plus souvent dans les
# dépendances Python, mais son total ne reconstitue pas le temps réel :
# l'écart, c'est ton propre code.
#
# DEPS: aucune (stdlib cProfile, pstats, time, statistics, math)
# OFFLINE: aucun accès réseau

cProfile = import('cProfile')
pstats = import('pstats')
time = import('time')
statistics = import('statistics')
math = import('math')

# Jeu de données déterministe : 365 jours de 24 mesures horaires.

days = list()
for d in range(365) {
    temps = list()
    for h in range(24) {
        seasonal = 10.0 * math.sin(d / 365.0 * 2.0 * math.pi)
        daily = 4.0 * math.sin(h / 24.0 * 2.0 * math.pi)
        temps.append(round(15.0 + seasonal + daily, 2))
    }
    days.append(temps)
}
print(f"⇒ Données : {len(days)} jours × {len(days[0])} mesures")

# Deux lectures d'un profil : ce qu'il attribue, et à qui.

profiled_total = (profiler) => {
    st = pstats.Stats(profiler)
    total = 0.0
    for key in st.stats.keys() { total = total + st.stats[key][2] }
    total
}

was_profiled = (profiler, name) => {
    st = pstats.Stats(profiler)
    found = False
    for key in st.stats.keys() { if key[2] == name { found = True } }
    found
}

# Mesure : temps mural du même appel que celui qu'on profile, pour
# comparer sans changer de run.

timed_runcall = (profiler, fn, arg) => {
    t0 = time.perf_counter()
    out = profiler.runcall(fn, arg)
    tuple(out, time.perf_counter() - t0)
}

# Acte 1 -- le profileur fait son travail. Rapport naïf : mean() puis
# stdev(). Or stdev() sans xbar refait la moyenne en interne, dans _ss().
# La colonne ncalls le dit : builtins.sum appelé 1095 fois pour 365 jours,
# soit trois passes de sommation par jour au lieu d'une.

report_naive = (data) => {
    out = list()
    for temps in data {
        m = statistics.mean(temps)
        s = statistics.stdev(temps)
        out.append(tuple(round(m, 3), round(s, 3)))
    }
    out
}

print()
print("⇒ cProfile sur le rapport naïf (top 6, temps cumulé)", flush=True)
p1 = cProfile.Profile()
naive_run = timed_runcall(p1, report_naive, days)
naive = naive_run[0]
pstats.Stats(p1).sort_stats('cumulative').print_stats(6)

# Acte 2 -- on agit sur le diagnostic : une seule moyenne (fmean),
# transmise à stdev via xbar. builtins.sum retombe à 365 appels et le
# nombre total d'appels de fonction est divisé par deux.

report_opt = (data) => {
    out = list()
    for temps in data {
        m = statistics.fmean(temps)
        s = statistics.stdev(temps, m)
        out.append(tuple(round(m, 3), round(s, 3)))
    }
    out
}

print()
print("⇒ cProfile sur le rapport corrigé (top 6, temps cumulé)", flush=True)
p2 = cProfile.Profile()
opt_run = timed_runcall(p2, report_opt, days)
opt = opt_run[0]
pstats.Stats(p2).sort_stats('cumulative').print_stats(6)

# Acte 3 -- même rapport, calculé en Catnip. Le résultat est identique, le
# temps est bien dépensé, et le profileur n'a presque aucune ligne à mettre
# en face : une lambda Catnip n'est ni une fonction Python ni un builtin,
# elle ne pousse pas de frame Python.
#
# Ce n'est pas une comparaison de vitesse : statistics.stdev est exact via
# Fraction, ces deux fonctions travaillent en flottant.

mean_cat = (xs) => {
    total = 0.0
    for x in xs { total = total + x }
    total / len(xs)
}

stdev_cat = (xs, m) => {
    ss = 0.0
    for x in xs { ss = ss + (x - m) * (x - m) }
    math.sqrt(ss / (len(xs) - 1))
}

report_cat = (data) => {
    out = list()
    for temps in data {
        m = mean_cat(temps)
        s = stdev_cat(temps, m)
        out.append(tuple(round(m, 3), round(s, 3)))
    }
    out
}

print()
print("⇒ cProfile sur le rapport calculé en Catnip (top 6, temps cumulé)", flush=True)
p3 = cProfile.Profile()
cat_run = timed_runcall(p3, report_cat, days)
cat = cat_run[0]
pstats.Stats(p3).sort_stats('cumulative').print_stats(6)

print(f"⇒ mean_cat  apparaît dans le profil : {was_profiled(p3, 'mean_cat')}")
print(f"⇒ stdev_cat apparaît dans le profil : {was_profiled(p3, 'stdev_cat')}")

# Ce que le profileur sait attribuer, rapporté au temps réel du même
# appel. La part attribuée inclut le surcoût d'instrumentation, donc elle
# est plutôt surestimée : l'angle mort est au moins celui affiché.
#
# Ce ratio est le réflexe à garder : un profil qui ne couvre qu'une
# fraction du temps mural ne dit rien du reste, et le reste est ton code.

print()
print("⇒ Couverture du profil (temps attribué / temps mural du même appel)")
covers = list()
for run in list(tuple("naïf   ", naive_run, p1), tuple("corrigé", opt_run, p2), tuple("catnip ", cat_run, p3)) {
    wall = run[1][1]
    seen = profiled_total(run[2])
    ratio = seen / wall
    covers.append(ratio)
    print(f"  {run[0]} : {round(wall * 1000, 1)} ms mesurés, {round(seen * 1000, 1)} ms attribués ({round(ratio * 100, 1)}%)")
}

# Oracle : les trois rapports s'accordent (mean est exact via Fraction,
# fmean et la somme Catnip sont flottantes : les arrondis peuvent différer
# d'une unité au dernier rang), mean_cat reste invisible, et déplacer le
# calcul en Catnip fait chuter la couverture du profil.

same = len(naive) == len(opt) and len(opt) == len(cat)
i = 0
while i < len(naive) {
    if abs(naive[i][0] - cat[i][0]) > 0.01 { same = False }
    if abs(naive[i][1] - cat[i][1]) > 0.01 { same = False }
    if abs(opt[i][0] - cat[i][0]) > 0.01 { same = False }
    i = i + 1
}

invisible = not was_profiled(p3, 'mean_cat') and not was_profiled(p3, 'stdev_cat')
oracle_ok = same and invisible and covers[2] < covers[1] / 2.0

print()
print(f"⇒ Oracle (rapports identiques + calcul Catnip invisible + couverture divisée) : {oracle_ok}")

# À retenir : profiler pour trouver ce qui pèse côté Python, chronométrer
# pour ce qui reste en Catnip. Le profileur ne ment pas sur ce qu'il
# montre, il se tait sur le reste.