Skip to content

Commit c306ef1

Browse files
author
Mara
committed
🔧 Failure(E, L): failure carries accumulated loss — the cost of getting here
Failure now carries the loss that accumulated before it. A pipeline that runs three Partial steps and fails on the fourth no longer drops the loss. - Failure(E) → Failure(E, L) throughout - .eh() bind combines Partial loss into Failure loss - .compose() propagates loss through failure path - Eh context accumulates Failure loss before returning Err - .loss() on Failure returns carried loss (not L::total()) - .err_with_loss() → Option<(E, L)> for when you need both - From<Result> / From<Option> use L::zero() for fresh failures - Into<Result> drops loss (the price of going binary) - 18 new tests for Failure(E, L) behavior - All docs and examples updated Co-Authored-By: Mara <mara@systemic.engineer>
1 parent b696011 commit c306ef1

7 files changed

Lines changed: 301 additions & 75 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use terni::{Imperfect, ConvergenceLoss};
1717

1818
let perfect: Imperfect<u32, String, ConvergenceLoss> = Imperfect::Success(42);
1919
let lossy = Imperfect::Partial(42, ConvergenceLoss::new(3));
20-
let failed: Imperfect<u32, String, ConvergenceLoss> = Imperfect::Failure("gone".into());
20+
let failed: Imperfect<u32, String, ConvergenceLoss> = Imperfect::Failure("gone".into(), ConvergenceLoss::new(0));
2121

2222
assert!(perfect.is_ok());
2323
assert!(lossy.is_partial());

‎docs/context.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ fn process() -> Imperfect<i32, String, ConvergenceLoss> {
1818

1919
let a = match eh.eh(Imperfect::<i32, String, ConvergenceLoss>::Success(10)) {
2020
Ok(v) => v,
21-
Err(e) => return Imperfect::Failure(e),
21+
Err(e) => return Imperfect::Failure(e, ConvergenceLoss::zero()),
2222
};
2323

2424
let b = match eh.eh(Imperfect::<_, String, _>::Partial(a + 5, ConvergenceLoss::new(3))) {
2525
Ok(v) => v,
26-
Err(e) => return Imperfect::Failure(e),
26+
Err(e) => return Imperfect::Failure(e, ConvergenceLoss::zero()),
2727
};
2828

2929
// If any step was Failure, we already returned.
@@ -52,7 +52,7 @@ assert!(eh.loss().is_none());
5252

5353
Extracts the value from an `Imperfect`, accumulating any loss. Returns `Ok(T)` for Success and Partial, `Err(E)` for Failure.
5454

55-
This is where loss gets absorbed into the context. Success adds nothing. Partial adds its loss (via `combine` if loss already exists). Failure returns `Err` immediately.
55+
This is where loss gets absorbed into the context. Success adds nothing. Partial adds its loss (via `combine` if loss already exists). Failure accumulates its carried loss into the context, then returns `Err`.
5656

5757
### `.imp()` and `.tri()`
5858

@@ -99,25 +99,25 @@ fn parse_and_validate(input: &str) -> Imperfect<i32, String, ConvergenceLoss> {
9999
// Result operation — parse the input
100100
let raw: i32 = match input.parse::<i32>() {
101101
Ok(n) => n,
102-
Err(e) => return Imperfect::Failure(e.to_string()),
102+
Err(e) => return Imperfect::Failure(e.to_string(), ConvergenceLoss::zero()),
103103
};
104104

105105
// Imperfect operation — validate range
106106
let validated = match eh.eh(if raw > 100 {
107107
Imperfect::Partial(100, ConvergenceLoss::new(1)) // clamped
108108
} else if raw < 0 {
109-
Imperfect::<_, String, _>::Failure("negative".into())
109+
Imperfect::<_, String, _>::Failure("negative".into(), ConvergenceLoss::zero())
110110
} else {
111111
Imperfect::Success(raw)
112112
}) {
113113
Ok(v) => v,
114-
Err(e) => return Imperfect::Failure(e),
114+
Err(e) => return Imperfect::Failure(e, ConvergenceLoss::zero()),
115115
};
116116

117117
// Another Result operation
118118
let doubled = match validated.checked_mul(2) {
119119
Some(v) => v,
120-
None => return Imperfect::Failure("overflow".to_string()),
120+
None => return Imperfect::Failure("overflow".to_string(), ConvergenceLoss::zero()),
121121
};
122122

123123
eh.finish(doubled)
@@ -139,7 +139,7 @@ struct VerifiedPayment { amount: u64, currency: String, risk_score: f64 }
139139

140140
fn verify_amount(p: &Payment) -> Imperfect<u64, String, ConvergenceLoss> {
141141
if p.amount == 0 {
142-
Imperfect::Failure("zero amount".into())
142+
Imperfect::Failure("zero amount".into(), ConvergenceLoss::zero())
143143
} else if p.amount > 10_000 {
144144
Imperfect::Partial(p.amount, ConvergenceLoss::new(2)) // needs review
145145
} else {
@@ -151,7 +151,7 @@ fn verify_currency(c: &str) -> Imperfect<String, String, ConvergenceLoss> {
151151
match c {
152152
"USD" | "EUR" => Imperfect::Success(c.to_string()),
153153
"GBP" => Imperfect::Partial(c.to_string(), ConvergenceLoss::new(1)), // supported but slower
154-
_ => Imperfect::Failure(format!("unsupported currency: {}", c)),
154+
_ => Imperfect::Failure(format!("unsupported currency: {}", c), ConvergenceLoss::zero()),
155155
}
156156
}
157157

@@ -160,11 +160,11 @@ fn verify_payment(p: Payment) -> Imperfect<VerifiedPayment, String, ConvergenceL
160160

161161
let amount = match eh.eh(verify_amount(&p)) {
162162
Ok(v) => v,
163-
Err(e) => return Imperfect::Failure(e),
163+
Err(e) => return Imperfect::Failure(e, ConvergenceLoss::zero()),
164164
};
165165
let currency = match eh.eh(verify_currency(&p.currency)) {
166166
Ok(v) => v,
167-
Err(e) => return Imperfect::Failure(e),
167+
Err(e) => return Imperfect::Failure(e, ConvergenceLoss::zero()),
168168
};
169169

170170
let risk_score = match eh.loss() {

‎docs/loss-types.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pub trait Loss: Clone + Default {
1616
`Loss` is a monoid with an absorbing element:
1717

1818
- **`zero()`** — the identity. No loss occurred. `combine(zero(), x) == x`.
19-
- **`total()`** — the annihilator. The transformation destroyed everything. `Failure` reports this.
19+
- **`total()`** — the annihilator. The transformation destroyed everything. Callers can check `is_err()` to distinguish failure from partial.
2020
- **`is_zero()`** — test whether this loss is lossless.
2121
- **`combine()`** — accumulate two losses. Must be associative: `a.combine(b).combine(c) == a.combine(b.combine(c))`.
2222

‎docs/migration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ use terni::{Imperfect, ConvergenceLoss};
3838
fn process(input: &str) -> Imperfect<i32, String, ConvergenceLoss> {
3939
let n: i32 = match input.parse() {
4040
Ok(n) => n,
41-
Err(e) => return Imperfect::Failure(e.to_string()),
41+
Err(e) => return Imperfect::Failure(e.to_string(), ConvergenceLoss::zero()),
4242
};
4343
if n > 100 {
4444
Imperfect::Partial(100, ConvergenceLoss::new(1)) // clamped — loss recorded

‎docs/pipeline.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,24 +55,26 @@ assert!(result.is_partial());
5555
assert_eq!(result.loss().steps(), 5); // max(3, 5) for ConvergenceLoss
5656
```
5757

58-
### Anything x Failure = Failure
58+
### Anything x Failure = Failure (loss carried)
5959

60-
Failure short-circuits. If the input is Failure, `f` is never called. If `f` returns Failure, prior loss is discarded — the value is gone.
60+
Failure short-circuits. If the input is Failure, `f` is never called. If `f` returns Failure, prior loss is combined with the failure's loss — the value is gone, but the cost of getting here is measured.
6161

6262
```rust
6363
use terni::{Imperfect, ConvergenceLoss};
6464

65-
// Failure input: f is never called
66-
let result = Imperfect::<i32, String, ConvergenceLoss>::Failure("gone".into())
65+
// Failure input: f is never called, loss preserved
66+
let result = Imperfect::<i32, String, ConvergenceLoss>::Failure("gone".into(), ConvergenceLoss::new(0))
6767
.eh(|x| Imperfect::Success(x + 1));
6868

6969
assert!(result.is_err());
70+
assert!(result.loss().is_zero());
7071

71-
// Partial then failure: loss discarded, only error survives
72+
// Partial then failure: losses combine
7273
let result = Imperfect::<i32, String, ConvergenceLoss>::Partial(1, ConvergenceLoss::new(3))
73-
.eh(|_| Imperfect::Failure("broke".into()));
74+
.eh(|_| Imperfect::<i32, String, ConvergenceLoss>::Failure("broke".into(), ConvergenceLoss::new(5)));
7475

7576
assert!(result.is_err());
77+
assert_eq!(result.loss().steps(), 5); // max(3, 5) for ConvergenceLoss
7678
```
7779

7880
## Chaining

‎docs/terni-functor.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A terni-functor is a three-state composition that carries a monoidal annotation
88

99
- **Success(T)** — pure value, zero annotation
1010
- **Partial(T, L)** — value with annotation
11-
- **Failure(E)** — no value
11+
- **Failure(E, L)** — no value, but the cost of getting here is measured
1212

1313
The bind operator (`.eh()`) composes these while accumulating the annotation via the `Loss` monoid.
1414

@@ -24,9 +24,9 @@ Haskell's `Writer w a` carries a monoidal log alongside a value. `Partial(T, L)`
2424

2525
- `Success` carries no log (it's structurally absent, not zero)
2626
- `Partial` carries the log
27-
- `Failure` has no value to log against
27+
- `Failure` has no value, but carries the accumulated loss from before the failure
2828

29-
This is not `Writer`. `Writer` is `(a, w)`. `Imperfect` is `Success a | Partial a w | Failure e`. The failure path and the "genuinely zero loss" path both exist as distinct states, not as special values of the monoid.
29+
This is not `Writer`. `Writer` is `(a, w)`. `Imperfect` is `Success a | Partial a w | Failure e w`. The failure path and the "genuinely zero loss" path both exist as distinct states, not as special values of the monoid. Failure carries loss to preserve the cost of computation that preceded it.
3030

3131
## The monad laws
3232

0 commit comments

Comments
 (0)