File size: 11,743 Bytes
6daf142
1607c63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6daf142
 
1607c63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6daf142
 
 
 
1607c63
 
 
 
 
6daf142
 
 
 
 
 
 
 
 
 
 
1607c63
6daf142
1607c63
 
6daf142
 
1607c63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6daf142
 
 
 
 
 
 
 
 
 
 
 
1607c63
 
6daf142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c86f59b
6daf142
abe58f7
6daf142
 
 
 
 
 
 
1607c63
 
6daf142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1607c63
 
 
6daf142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1607c63
6daf142
63c4b56
 
c86f59b
1607c63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
"""
Multi-phase reward computation for the API Contract Validator.

This module follows the OpenEnv "composable rubric" pattern: each reward
signal is an independent ``RubricComponent`` and the total step reward is
the sum of its components. Independent components have two key benefits:

  1. Reduces reward-hacking risk β€” an agent that maximises one component
     while degrading another shows up immediately in per-component logs.
  2. Gives RL training a richer gradient β€” partial progress on one
     component still produces signal even when others are zero.

Phase reward signals
--------------------

Phase 1 β€” Detection (legacy ``compute_step_reward`` helper kept for
back-compat with the existing tests; new code should use the rubric API):

  - correct violation         +1.0
  - proximity match           +0.3
  - hint requested            -0.5
  - duplicate report          -0.1
  - false positive            -0.3
  - DONE bonus                +0.5 * (correct / total)

Phase 2 β€” Impact Tracing:

  - correct consumer hit      +0.8 each
  - missed consumer           -0.5 each
  - false-flag consumer       -0.4 each
  - unknown service name      -0.2 each (sub-rule of false-flag)

Phase 3 β€” Fix & Verify:

  - fix passes ALL consumers  +2.0
  - fix breaks 1+ consumer    -1.0
  - malformed spec patch      -0.5
  - unacceptable strategy     -0.3

Cross-cutting:

  - format compliance         -0.2 for malformed action JSON
  - anti-hacking (spam)       -1.0 if total reports > 3 * planted violations
"""

from dataclasses import dataclass, field
from typing import List

from .fix_validator import FixValidationResult
from .impact_tracer import ImpactTraceResult


# ── Rubric primitives ────────────────────────────────────────────────────


@dataclass
class RubricComponent:
    """A single named reward signal."""

    name: str
    score: float
    explanation: str = ""


@dataclass
class Rubric:
    """Composition of independent reward signals.

    The ``total`` property sums every component. Components are kept
    individually so logs and training analysis can show which signal
    moved across episodes (the key requirement for the "Pipeline 10%"
    judging criterion).
    """

    components: List[RubricComponent] = field(default_factory=list)

    def add(self, name: str, score: float, explanation: str = "") -> "Rubric":
        self.components.append(
            RubricComponent(name=name, score=score, explanation=explanation)
        )
        return self

    @property
    def total(self) -> float:
        return sum(c.score for c in self.components)

    def to_dict(self) -> dict:
        return {
            "total": round(self.total, 4),
            "components": [
                {
                    "name": c.name,
                    "score": round(c.score, 4),
                    "explanation": c.explanation,
                }
                for c in self.components
            ],
        }


# ── Phase 1 β€” detection (legacy scalar helper kept for backwards compat) ─


@dataclass
class RewardBreakdown:
    """Detailed breakdown of a single Phase 1 step reward.

    Kept for backwards compatibility with the existing Phase 1 tests and
    inference loop. New phases use ``Rubric`` directly.
    """

    reward: float
    is_correct: bool
    is_path_match: bool
    is_duplicate: bool
    is_false_positive: bool
    is_done_signal: bool
    is_hint: bool
    explanation: str


# Phase 1 reward constants
CORRECT_VIOLATION_REWARD = 1.0
PATH_MATCH_REWARD = 0.3
HINT_PENALTY = -0.5
DUPLICATE_PENALTY = -0.1
FALSE_POSITIVE_PENALTY = -0.3
DONE_BONUS_MULTIPLIER = 0.5

# Phase 2 reward constants
CORRECT_CONSUMER_REWARD = 0.8
MISSED_CONSUMER_PENALTY = -0.5
FALSE_FLAG_PENALTY = -0.4
UNKNOWN_SERVICE_PENALTY = -0.2

# Phase 3 reward constants
FIX_PASSES_ALL_REWARD = 2.0
FIX_BREAKS_CONSUMER_PENALTY = -1.0
MALFORMED_PATCH_PENALTY = -0.5
UNACCEPTABLE_STRATEGY_PENALTY = -0.3

# Cross-cutting
MALFORMED_ACTION_PENALTY = -0.2
SPAM_PENALTY = -1.0
SPAM_THRESHOLD_MULTIPLIER = 3


def compute_step_reward(
    *,
    is_correct: bool,
    is_path_match: bool = False,
    is_duplicate: bool,
    is_done_signal: bool,
    is_hint: bool = False,
    correct_so_far: int,
    total_violations: int,
) -> RewardBreakdown:
    """Compute a Phase 1 detection step reward (legacy scalar API)."""

    if is_hint:
        return RewardBreakdown(
            reward=HINT_PENALTY,
            is_correct=False,
            is_path_match=False,
            is_duplicate=False,
            is_false_positive=False,
            is_done_signal=False,
            is_hint=True,
            explanation="Hint requested. -0.5 reward.",
        )

    if is_done_signal:
        completeness = correct_so_far / max(total_violations, 1)
        bonus = DONE_BONUS_MULTIPLIER * completeness
        bonus = round(max(0.01, min(0.99, bonus)), 4)
        return RewardBreakdown(
            reward=bonus,
            is_correct=False,
            is_path_match=False,
            is_duplicate=False,
            is_false_positive=False,
            is_done_signal=True,
            is_hint=False,
            explanation=(
                f"Agent signalled DONE. Completeness "
                f"{correct_so_far}/{total_violations} β†’ bonus {bonus:.2f}"
            ),
        )

    if is_duplicate:
        return RewardBreakdown(
            reward=DUPLICATE_PENALTY,
            is_correct=False,
            is_path_match=False,
            is_duplicate=True,
            is_false_positive=False,
            is_done_signal=False,
            is_hint=False,
            explanation="Duplicate violation report β€” already submitted.",
        )

    if is_correct:
        return RewardBreakdown(
            reward=CORRECT_VIOLATION_REWARD,
            is_correct=True,
            is_path_match=False,
            is_duplicate=False,
            is_false_positive=False,
            is_done_signal=False,
            is_hint=False,
            explanation="Correct! Violation matches ground truth.",
        )

    if is_path_match:
        return RewardBreakdown(
            reward=PATH_MATCH_REWARD,
            is_correct=False,
            is_path_match=True,
            is_duplicate=False,
            is_false_positive=False,
            is_done_signal=False,
            is_hint=False,
            explanation=(
                "Correct field location! The field_path matches a "
                "violation, but the violation_type is wrong. Try again "
                "with the right type."
            ),
        )

    return RewardBreakdown(
        reward=FALSE_POSITIVE_PENALTY,
        is_correct=False,
        is_path_match=False,
        is_duplicate=False,
        is_false_positive=True,
        is_done_signal=False,
        is_hint=False,
        explanation="False positive β€” no matching violation in ground truth.",
    )


def compute_episode_score(correct_count: int, total_violations: int) -> float:
    """Final Phase 1 normalised score, strictly in (0, 1)."""
    if total_violations == 0:
        return 0.5
    raw = correct_count / total_violations
    return round(max(0.01, min(0.99, raw)), 4)


# ── Phase 2 β€” impact tracing (Rubric API) ────────────────────────────────


def phase2_trace_rubric(result: ImpactTraceResult) -> Rubric:
    """Build a per-consumer Rubric from a Phase 2 impact-trace result."""
    rubric = Rubric()

    for hit in result.correct_hits:
        rubric.add(
            name=f"consumer_correct:{hit}",
            score=CORRECT_CONSUMER_REWARD,
            explanation=f"Correctly identified affected consumer '{hit}'.",
        )
    for missed in result.missed:
        rubric.add(
            name=f"consumer_missed:{missed}",
            score=MISSED_CONSUMER_PENALTY,
            explanation=f"Missed affected consumer '{missed}'.",
        )
    for flagged in result.false_flags:
        rubric.add(
            name=f"consumer_false_flag:{flagged}",
            score=FALSE_FLAG_PENALTY,
            explanation=(
                f"False-flagged unaffected consumer '{flagged}'."
            ),
        )
    for unknown in result.unknown_services:
        rubric.add(
            name=f"unknown_service:{unknown}",
            score=UNKNOWN_SERVICE_PENALTY,
            explanation=f"'{unknown}' is not a known service in this graph.",
        )
    return rubric


def phase2_episode_score(result: ImpactTraceResult) -> float:
    """Phase 2 final score = F1, clamped to (0.01, 0.99)."""
    return round(max(0.01, min(0.99, result.f1)), 4)


# ── Phase 3 β€” fix validation (Rubric API) ────────────────────────────────


def phase3_fix_rubric(result: FixValidationResult) -> Rubric:
    """Build a Rubric from a Phase 3 fix-validation result."""
    rubric = Rubric()

    if not result.is_well_formed:
        rubric.add(
            name="malformed_patch",
            score=MALFORMED_PATCH_PENALTY,
            explanation="; ".join(result.notes) or "Malformed spec patch.",
        )
        return rubric

    if not result.is_strategy_acceptable:
        rubric.add(
            name="strategy_unacceptable",
            score=UNACCEPTABLE_STRATEGY_PENALTY,
            explanation=(
                f"Strategy '{result.strategy}' is not appropriate for this "
                f"scenario."
            ),
        )

    if result.all_consumers_pass:
        rubric.add(
            name="fix_passes_all_consumers",
            score=FIX_PASSES_ALL_REWARD,
            explanation=(
                f"Fix using strategy '{result.strategy}' validates against "
                f"all {len(result.consumers_passing)} consumer(s)."
            ),
        )
    else:
        for consumer, reason in result.failure_reasons.items():
            rubric.add(
                name=f"fix_breaks_consumer:{consumer}",
                score=FIX_BREAKS_CONSUMER_PENALTY,
                explanation=(
                    f"Fix breaks consumer '{consumer}': {reason}."
                ),
            )

    return rubric


def phase3_episode_score(result: FixValidationResult) -> float:
    """Phase 3 final score: 0.99 if all consumers pass else proportional."""
    if not result.is_well_formed:
        return 0.01
    total = len(result.consumers_passing) + len(result.consumers_failing)
    if total == 0:
        return 0.01
    raw = len(result.consumers_passing) / total
    return round(max(0.01, min(0.99, raw)), 4)


# ── Cross-cutting signals ────────────────────────────────────────────────


def malformed_action_component() -> RubricComponent:
    """Penalty for an action JSON that fails schema validation."""
    return RubricComponent(
        name="malformed_action",
        score=MALFORMED_ACTION_PENALTY,
        explanation="Action did not match the expected schema.",
    )


def spam_penalty_component(reports: int, planted: int) -> RubricComponent | None:
    """Anti-hacking: agent reporting > 3Γ— planted violations is spamming."""
    if planted <= 0:
        return None
    if reports > SPAM_THRESHOLD_MULTIPLIER * planted:
        return RubricComponent(
            name="spam_penalty",
            score=SPAM_PENALTY,
            explanation=(
                f"Reported {reports} violations against {planted} planted "
                f"β€” exceeds {SPAM_THRESHOLD_MULTIPLIER}Γ— threshold."
            ),
        )
    return None