Moving from Result<T, E> to Imperfect<T, E, L>. You don't have to convert everything at once.
| Result | terni | |
|---|---|---|
Ok(v) |
Imperfect::Success(v) |
same |
Err(e) |
Imperfect::Failure(e, l) |
same |
Imperfect::Partial(v, l) |
new | |
Imperfect::Failure(e, l) |
honest |
The two empty cells on the left are the argument. Result doesn't have a row for partial success or honest failure. That's why terni exists.
Convenience constructors: .success(v), .partial(v, l), .failure(e) (zero loss), .failure_with_loss(e, l) (carries accumulated loss).
Failure(E, L) carries accumulated loss — the cost of getting here. Result::Err carries only the error. The loss is information you can't recover from the error alone: how much work happened before the failure, how close you were, what was already spent.
What does "partial success" mean in your domain?
| If your code does... | Use |
|---|---|
| Iterative refinement, convergence loops | ConvergenceLoss |
| Partial observation, missing dimensions | ApertureLoss |
| Routing decisions, classifier selection | RoutingLoss |
| Something else | Implement your own |
Start with one function. Replace Result<T, E> with Imperfect<T, E, L>.
Before:
fn process(input: &str) -> Result<i32, String> {
let n: i32 = input.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
if n > 100 {
Ok(100) // silently clamped — information lost
} else {
Ok(n)
}
}After:
use terni::{Imperfect, ConvergenceLoss};
fn process(input: &str) -> Imperfect<i32, String, ConvergenceLoss> {
let n: i32 = match input.parse() {
Ok(n) => n,
Err(e) => return Imperfect::Failure(e.to_string(), ConvergenceLoss::zero()),
};
if n > 100 {
Imperfect::Partial(100, ConvergenceLoss::new(1)) // clamped — loss recorded
} else {
Imperfect::Success(n)
}
}The information that was silently discarded is now measured and carried.
Callers that used ? on Result can use Eh to work with Imperfect:
Before:
# fn process(_: &str) -> Result<i32, String> { Ok(1) }
fn run(a: &str, b: &str) -> Result<i32, String> {
let x = process(a)?;
let y = process(b)?;
Ok(x + y)
}After:
use terni::{Imperfect, Eh, ConvergenceLoss};
# fn process(_: &str) -> Imperfect<i32, String, ConvergenceLoss> {
# Imperfect::Success(1)
# }
fn run(a: &str, b: &str) -> Imperfect<i32, String, ConvergenceLoss> {
let mut eh = Eh::new();
let x = eh.eh(process(a)).unwrap_or_else(|e| panic!("{}", e));
let y = eh.eh(process(b)).unwrap_or_else(|e| panic!("{}", e));
eh.finish(x + y)
}Or use the pipeline directly:
use terni::{Imperfect, ConvergenceLoss};
# fn process(_: &str) -> Imperfect<i32, String, ConvergenceLoss> {
# Imperfect::Success(1)
# }
fn run(a: &str, b: &str) -> Imperfect<i32, String, ConvergenceLoss> {
process(a).eh(|x| process(b).map(|y| x + y))
}From conversions let Imperfect and Result coexist:
use terni::{Imperfect, ConvergenceLoss};
// Result → Imperfect (Ok becomes Success, Err becomes Failure)
let from_result: Imperfect<i32, String, ConvergenceLoss> =
Ok::<i32, String>(42).into();
assert!(from_result.is_ok());
// Imperfect → Result (Success and Partial both become Ok, loss is discarded)
let back: Result<i32, String> =
Imperfect::<i32, String, ConvergenceLoss>::Partial(42, ConvergenceLoss::new(3)).into();
assert_eq!(back, Ok(42));
// Option → Imperfect (Some becomes Success, None becomes Failure(()))
let from_option: Imperfect<i32, (), ConvergenceLoss> = Some(42).into();
assert!(from_option.is_ok());Convert at the boundaries. Functions that return Imperfect can be called by code that only understands Result — just .into() or use Result::from(). Loss is discarded on that conversion, but it's explicit.
You don't need to convert your entire codebase. Convert the functions where partial success matters — where you're currently discarding information by collapsing to Ok or Err. The rest can stay as Result.
Result has .unwrap_or() and .unwrap_or_else(). So does Imperfect — but recovery from Failure always produces Partial, never Success. The failure happened. The cost is real.
use terni::{Imperfect, ConvergenceLoss};
// unwrap_or: static default
let failed: Imperfect<i32, String, ConvergenceLoss> =
Imperfect::Failure("gone".into(), ConvergenceLoss::new(5));
let recovered = failed.unwrap_or(0);
assert!(recovered.is_partial()); // never Success
assert_eq!(recovered.ok(), Some(0));
assert_eq!(recovered.loss().steps(), 5); // cost survives
// recover: full control
let failed: Imperfect<i32, String, ConvergenceLoss> =
Imperfect::Failure("gone".into(), ConvergenceLoss::new(3));
let recovered = failed.recover(|_e| Imperfect::Success(42));
assert!(recovered.is_partial()); // recovery from Failure → always Partial
assert_eq!(recovered.ok(), Some(42));
assert_eq!(recovered.loss().steps(), 3);
// err_with_loss: extract both error and accumulated loss
let failed: Imperfect<i32, String, ConvergenceLoss> =
Imperfect::Failure("gone".into(), ConvergenceLoss::new(7));
let (error, loss) = failed.err_with_loss().unwrap();
assert_eq!(error, "gone");
assert_eq!(loss.steps(), 7);prism-core migrated from ShannonLoss (a former terni type) to a local ScalarLoss when terni removed ShannonLoss upstream. The migration also replaced manual enum variant constructors with terni's constructor methods.
Before:
// ShannonLoss from terni (removed upstream)
use terni::ShannonLoss;
Imperfect::Success(output)
Imperfect::Partial(output, ShannonLoss::new(bits))
Imperfect::Failure(error, ShannonLoss::new(0.0))After:
// ScalarLoss — core's own Loss impl
use prism_core::ScalarLoss;
Imperfect::success(output)
Imperfect::partial(output, ScalarLoss::new(bits))
Imperfect::failure(error) // zero loss by defaultKey decisions in the migration:
-
Own the loss type. When the upstream type changed, core defined
ScalarLosslocally — a 39-line file implementing theLossmonoid with additivecombine. No external dependency for a domain-specific measurement. -
Constructor methods over enum variants.
Imperfect::failure(e)replacesImperfect::Failure(e, L::zero())— the zero loss is the common case and shouldn't require spelling out.Imperfect::failure_with_loss(e, l)for the rare case where accumulated loss needs explicit attachment. -
Gradual. The
Beamtrait'stickprimitive still pattern-matches onImperfectvariants directly — because it needs to destructure the inner values to build new beam structs. The refactor targeted constructors and leaf code, not the pipeline primitive.
Total diff: 6 files changed, 27 insertions, 27 deletions. All 182 tests pass unchanged.