Skip to content
This repository was archived by the owner on Jul 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ def test_as_dict_keys(self, metrics):
def test_kappa_increases_with_dissonance(self, parsed, canon):
from edcmbone.metrics.compute import compute_round, energy_step
# Artificially high dissonance -> kappa should grow
_, kappa = energy_step(0.0, 0.0, dissonance=0.9, alpha=0.9, delta_max=0.1)
_, kappa = energy_step(0.0, 0.9, alpha=0.9, delta_max=0.1)
assert kappa > 0.0


Expand Down
31 changes: 14 additions & 17 deletions backend/src/edcmbone/metrics/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,24 @@
O Overconfidence [-1,1]
L Coherence loss [0,1]
P Progress [0,1]
k Stored tension ≥ 0
k Stored tension κ ∈ [0, 1]

Most metrics are partially computable from markers (phrase-level signals).
Some require embeddings / cross-turn semantic comparison (marked below).

Public API
----------
RoundMetrics — data class holding all computed values
compute_round(round_, prev_round, canon, alpha, delta_max) -> RoundMetrics
energy_step(prev, metrics, alpha, delta_max) -> (E_t, s_t)
compute_round(round_, prev_round, canon, alpha, delta_max[, prev_kappa, prev_entropy]) -> RoundMetrics
energy_step(prev_kappa, dissonance, alpha, delta_max) -> (E_t, s_t)
"""

from __future__ import annotations

import math
import re
from collections import Counter

from edcmbone.canon import CanonLoader
from edcmbone.parser.turns_rounds import Round, BoneToken
from edcmbone.parser.turns_rounds import Round

from .stats import (
clamp,
Expand Down Expand Up @@ -81,7 +79,8 @@ class RoundMetrics:

def __init__(self, **kwargs):
for k in self.__slots__:
setattr(self, k, kwargs.get(k, 0.0))
default = 0 if k in ("round_index", "token_count", "bone_count") else 0.0
setattr(self, k, kwargs.get(k, default))

def as_dict(self):
return {k: getattr(self, k) for k in self.__slots__}
Expand Down Expand Up @@ -125,7 +124,7 @@ def _count_marker_hits(text, pattern):
# Circuit dynamics
# ---------------------------------------------------------------------------

def energy_step(prev_energy, prev_kappa, dissonance, alpha=0.85, delta_max=0.3):
def energy_step(prev_kappa, dissonance, alpha=0.85, delta_max=0.3):
Comment thread
erinepshovel-code marked this conversation as resolved.
"""Compute one step of the RC-circuit energy model.

s_{t+1} = alpha * s_t + E_t - delta_t
Expand Down Expand Up @@ -235,9 +234,11 @@ def _compute_C(round_text, canon):


def _compute_D(tokens_b, tokens_a, round_text, canon):
"""Deflection — partial; low bone density as proxy."""
# Deflection = 1 - (tokens about constraints / total).
# Proxy: low cosine overlap with prior round = deflecting.
"""Deflection — partial; 1 - cosine similarity with prior round as proxy.

Low cosine overlap with the prior round is treated as deflection:
high similarity = on-topic = low deflection.
"""
if not tokens_a:
return 0.0
cos = cosine_sim(tokens_b, tokens_a)
Expand All @@ -258,7 +259,7 @@ def _compute_E(round_text, tokens_b, tokens_a, canon):

def compute_round(round_, prev_round=None, canon=None,
alpha=0.85, delta_max=0.3,
prev_kappa=0.0, prev_energy=0.0, prev_entropy=0.0):
prev_kappa=0.0, prev_entropy=0.0):
"""Compute the metric vector for a Round.
Comment on lines 260 to 263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep compute_round compatible with prev_energy callers

Removing prev_energy from compute_round is a backward-incompatible API change for existing integrations that still pass prev_energy= (the parameter existed in the prior signature and this function is exported via edcmbone.metrics). In this commit those calls now fail immediately with TypeError: unexpected keyword argument 'prev_energy', even though the value was previously ignored, so preserving it as an optional no-op argument would avoid breaking downstream code.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex[agent] none downstream yet

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Restored backward compatibility in compute_round by reintroducing an optional prev_energy parameter so downstream callers that still pass prev_energy= no longer fail with TypeError. The value is accepted as a no-op.
  • Updated the compute_round docstring to explicitly document prev_energy as a deprecated compatibility argument that is accepted but ignored.
  • Committed the fix on the current branch as commit 2735ef4 and created a follow-up PR message via make_pr per instructions.

Testing

  • python -m py_compile backend/src/edcmbone/metrics/compute.py

View task →


Parameters
Expand All @@ -269,7 +270,6 @@ def compute_round(round_, prev_round=None, canon=None,
alpha : persistence coefficient for the RC circuit [0, 1]
delta_max : max resolution rate per step [0, 1]
prev_kappa : stored tension from previous step (κ_{t-1})
prev_energy : dissonance energy from previous step (ε_{t-1})
prev_entropy: Shannon entropy of previous round (for progress computation)

Returns
Expand Down Expand Up @@ -305,7 +305,7 @@ def compute_round(round_, prev_round=None, canon=None,
dissonance = clamp((C + R + F + E + N + I + L) / 7.0)

# Circuit dynamics
_, new_kappa = energy_step(prev_energy, prev_kappa, dissonance, alpha, delta_max)
_, new_kappa = energy_step(prev_kappa, dissonance, alpha, delta_max)

Comment thread
erinepshovel-code marked this conversation as resolved.
return RoundMetrics(
C=C, R=R, F=F, E=E, D=D, N=N, I=I, O=O, L=L, P=P,
Expand All @@ -328,7 +328,6 @@ def compute_transcript(parsed_transcript, canon=None, alpha=0.85, delta_max=0.3)
results = []
prev_round = None
prev_kappa = 0.0
prev_energy = 0.0
prev_entropy = 0.0

for rnd in parsed_transcript.rounds:
Expand All @@ -339,14 +338,12 @@ def compute_transcript(parsed_transcript, canon=None, alpha=0.85, delta_max=0.3)
alpha=alpha,
delta_max=delta_max,
prev_kappa=prev_kappa,
prev_energy=prev_energy,
prev_entropy=prev_entropy,
)
results.append(m)

prev_round = rnd
prev_kappa = m.kappa
prev_energy = m.dissonance_energy
prev_entropy = shannon_entropy(tokenize(" ".join(t.text for t in rnd.turns)))

return results
15 changes: 13 additions & 2 deletions backend/src/edcmbone/metrics/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,25 @@
It is the only signed metric. Callers that need [0, 1] should use
abs(O) or (O + 1) / 2 as appropriate; the signed range is preserved
here for full fidelity.
- κ (kappa) is ≥ 0, unbounded. It is a state variable (RC-circuit
stored tension), not a metric in the strict [0,1] sense.
- κ (kappa) ∈ [0, 1]. It is a state variable (RC-circuit stored
tension) clamped to [0, 1] by energy_step(). PROJECTION_MAP["DA"]
also requires κ ∈ [0, 1].
- P (Progress) is the only health metric; it is *subtracted* in
composite risk (−β₆P in the logistic formulation).

Freezing
--------
Call freeze(matrix) to produce an immutable, version-stamped copy
suitable for embedding in compressed artefacts.

Note on A_MATRIX vs compute.py
------------------------------
A_MATRIX is currently documentation-shaped: it records the intended
weights and primitive inputs for each Layer 1 metric, but the actual
computation weights are hardcoded in compute.py (functions _compute_*).
A_MATRIX should eventually become the single source of truth so that
changing a weight here automatically changes the computation. Until
that refactor is complete, keep both in sync manually.
"""

from __future__ import annotations
Expand Down Expand Up @@ -86,6 +96,7 @@
"non_novelty": 0.40,
},
# E: escalation — refusal signal + loop risk
# NOTE: E depends on Layer 1 metric R — Layer mixing; see issue #38
"E": {
"R": 0.60, # R is itself a Layer 1 metric here
"loop_risk": 0.40,
Expand Down
11 changes: 9 additions & 2 deletions backend/src/edcmbone/metrics/projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ def gini_tbf(turns):
Normalised to [0, 1] (not [0, 1-1/n]).
"""
from collections import Counter
counts = Counter(t.token_count for t in turns)
# Re-count per speaker
# Count tokens per speaker
speaker_counts = Counter()
for turn in turns:
speaker_counts[turn.speaker] += turn.token_count
Expand Down Expand Up @@ -148,6 +147,14 @@ def fire_alerts(agent_metrics):
val = getattr(agent_metrics, spec["metric"])
if spec["direction"] == "above" and val > spec["threshold"]:
fired.append(name)
elif spec["direction"] == "below" and val < spec["threshold"]:
fired.append(name)
elif spec["direction"] not in ("above", "below"):
raise ValueError(
"Unknown alert direction {!r} for alert {!r}".format(
spec["direction"], name
)
)
return fired


Expand Down
4 changes: 2 additions & 2 deletions backend/src/edcmbone/metrics/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ def fixation_risk(tokens_b, tokens_a):
def broken_return(tokens_a, tokens_b, tokens_c):
"""Broken-return sub-component.

R_broken = 0.55 * cos(c_A, c_B) + 0.45 * (1 - J(T_C, T_B))
R_broken = clamp(0.55 * cos(c_A, c_B) + 0.45 * (1 - J(T_C, T_B)))

tokens_a : original response A
tokens_b : new response B
tokens_c : correction/target C
"""
cos = cosine_sim(tokens_a, tokens_b)
j = jaccard(set(tokens_c), set(tokens_b))
return 0.55 * cos + 0.45 * (1.0 - j)
return clamp(0.55 * cos + 0.45 * (1.0 - j))


def escalation_risk(tokens_a, tokens_b, tokens_c, refusal_density, hedge_density):
Expand Down
Loading