Skip to content

Latest commit

 

History

History
205 lines (150 loc) · 7.26 KB

File metadata and controls

205 lines (150 loc) · 7.26 KB

Migration

Moving from Result<T, E> to Imperfect<T, E, L>. You don't have to convert everything at once.

The table

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.

Step 1: Choose a Loss type

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

Step 2: Convert return types

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.

Step 3: Convert callers

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))
}

Step 4: Gradual adoption

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.

Step 5: Recovery

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);

Case study: prism-core

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 default

Key decisions in the migration:

  1. Own the loss type. When the upstream type changed, core defined ScalarLoss locally — a 39-line file implementing the Loss monoid with additive combine. No external dependency for a domain-specific measurement.

  2. Constructor methods over enum variants. Imperfect::failure(e) replaces Imperfect::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.

  3. Gradual. The Beam trait's tick primitive still pattern-matches on Imperfect variants 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.

Back to README · Loss types →