Answering "is A a subtype of B?"

The most common analyser question. Recipe with the full setup, expansion, and result-handling.

The pattern

use suffete::{TypeId, lattice::{self, LatticeOptions, LatticeReport}, predicates::is_fully_resolved, expand};
use suffete::world::World;

fn is_subtype<W: World>(
    sub:    TypeId,
    sup:    TypeId,
    world:  &W,
) -> (bool, LatticeReport) {
    // 1. Expand any unresolved Elements (Alias, Conditional, Derived, ...).
    let sub = if is_fully_resolved(sub) { sub } else { expand::expand(sub, world) };
    let sup = if is_fully_resolved(sup) { sup } else { expand::expand(sup, world) };

    // 2. Configure lattice options.
    let opts = LatticeOptions::default();

    // 3. Run the lattice query, collecting report side-info.
    let mut report = LatticeReport::new();
    let result = lattice::refines(sub, sup, world, opts, &mut report);

    (result, report)
}

Reading the result

The (bool, LatticeReport) pair is structured for diagnostic emission:

  • bool == true ; the subtype check holds.
  • bool == false ; the check failed; the analyser surfaces a type error.
  • report.causes ; bitset of which coercion-tolerant rules fired. Surface in the diagnostic if you want to warn (e.g. a default-filled template parameter was tolerated, recorded as CoercionCauses::TEMPLATE_DEFAULT).

Options

refines is always strict at the value level: it does not admit PHP's runtime coercions. int does not refine float, and numeric-string does not refine int, regardless of declare(strict_types). The PHP runtime-coercion story lives elsewhere: see compatibility::runtime_compatible and cast.

The knobs LatticeOptions does expose are narrower:

  • ignore_null / ignore_false: drop null / false from the input union before the check, for analyser code that has separately verified non-nullability or non-falsability. Set via LatticeOptions::default().with_ignore_null().
  • inside_assertion: marks that the check runs inside a runtime assertion, which makes a few rules more permissive.

Worked examples

Direct subtype

use suffete::{TypeBuilder, prelude::{INT, STRING}};

let int_t  = TypeBuilder::new().push(INT).build();
let int_or_str = TypeBuilder::new().push(INT).push(STRING).build();

let (ok, _report) = is_subtype(int_t, int_or_str, &world);
assert!(ok);

Int does not refine float

use suffete::{TypeBuilder, prelude::{INT, FLOAT}};

let int_t = TypeBuilder::new().push(INT).build();
let float_t = TypeBuilder::new().push(FLOAT).build();

let (ok, _) = is_subtype(int_t, float_t, &world);
assert!(!ok);  // int does not refine float.

Class-hierarchy subtype

use suffete::{TypeBuilder, ElementId};

// Setup (in your World): class B extends A.

let b_t = TypeBuilder::new().push(ElementId::object_named(b"B")).build();
let a_t = TypeBuilder::new().push(ElementId::object_named(b"A")).build();

let (ok, _) = is_subtype(b_t, a_t, &world);
assert!(ok);

Generic subtype with variance

use suffete::element::payload::{ObjectInfo, ObjectFlags};
use suffete::interner::interner;

// World registers Iterator<T> as covariant on T.

let int_iter = TypeBuilder::new().push(interner().intern_object(ObjectInfo {
    name: mago_word::word(b"Iterator"),
    type_args: Some(interner().intern_type_list(&[suffete::prelude::TYPE_INT])),
    flags: ObjectFlags::default(),
})).build();
let mixed_iter = TypeBuilder::new().push(interner().intern_object(ObjectInfo {
    name: mago_word::word(b"Iterator"),
    type_args: Some(interner().intern_type_list(&[suffete::prelude::TYPE_MIXED])),
    flags: ObjectFlags::default(),
})).build();

let (ok, _) = is_subtype(int_iter, mixed_iter, &world);
assert!(ok);  // covariance: int <: mixed

Failure with a useful report

let foo = TypeBuilder::new().push(ElementId::object_named(b"Foo")).build();
let bar = TypeBuilder::new().push(ElementId::object_named(b"Bar")).build();

let (ok, report) = is_subtype(foo, bar, &world);
assert!(!ok);

// report contains structured information you can use to construct
// a diagnostic message, like which family of rules failed.

Performance

refines is the hot path. Suffete optimises for the common cases:

  • Reflexivity: refines(t, t) = true in one comparison.
  • Singleton-vs-singleton: most analyser queries are between one-Element types; the cartesian is degenerate.
  • Subsumption shortcut in unions: if any Element on the left refines any Element on the right by the universal axioms (top, bot, placeholder), short-circuit.

Expected cost for a typical analyser query: tens of nanoseconds. Worst-case (deep generics, multi-conjunct intersections, full fan-out coverage): microseconds.

When to expand

Expand exactly once per type, before the first lattice query. The expansion result is itself a TypeId and can be cached per (input, world-version) if the analyser sees it many times.

The is_fully_resolved check is cheap (a tree walk with a short-circuiting predicate), so the recipe above always-checks-then-expands. For analyser code that knows expansion has already happened (e.g. inside a single statement's analysis), skip the check.

When to skip the world

If the question doesn't require the world (e.g. is_subtype(int_or_string, int)), you can pass NullWorld:

use suffete::world::NullWorld;
let (ok, _) = is_subtype(int_or_string, int_t, &NullWorld);

The lattice queries the world only when needed. For fully-structural inputs, the world is never asked.

See also: refines for the rules; overlaps for the symmetric "share a value?" question; LatticeOptions for the ignore_null and ignore_false options.