From d3ed77f2b72d8f5bcac5e3a937cd97613aaa8af6 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 20 Jul 2026 10:08:48 +0100 Subject: [PATCH 01/51] docs: Add and polish TYPE_ALGEBRA.md for libfn composition model Add a comprehensive, mathematically rigorous technical specification of the type-level algebra and functional-composition model of libfn/functional. - Define core algebraic vocabulary (pack, copack, expected, optional, choice, and just) and map them to their corresponding ADT cardinalities (0, 1, A+B, AxB). - Detail graded error-set unioning (widening/subeffecting) and outline implicit-rejection safety boundaries alongside explicit narrowing via transform_error. - Document identity cluster transitions, explaining the decoupling design (member functions strict to carriers vs. namespace fn:: pipeline operators). - Explain type-tagged elimination (apply_type) using C++ standard state and constructor tags (std::in_place, fn::unexpect, std::nullopt, std::in_place_type). - Consolidate prose, remove redundant descriptions, and include 100% compile-verified C++20 code blocks demonstrating same-kind concept boundaries, pack::append splicing, and Cartesian product distribution. Assisted-by: Gemini:gemini-3.5-flash --- TYPE_ALGEBRA.md | 949 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 949 insertions(+) create mode 100644 TYPE_ALGEBRA.md diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md new file mode 100644 index 00000000..90e04065 --- /dev/null +++ b/TYPE_ALGEBRA.md @@ -0,0 +1,949 @@ +# Type algebra and functional composition in libfn + +The `libfn` library is a C++20 functional programming framework that lets the compiler derive both the values and the complete static shape of a computation. Instead of relying on type erasure, exceptions, or monolithic sum types, `libfn` tracks the precise algebraic combinations of success, alternative, and error states during composition. + +The library operates on a few core vocabulary types: + +- `pack`: Product type containing all fields. +- `copack`: Canonical coproduct containing exactly one alternative. +- `optional`: Computation yielding a value or empty. +- `expected`: Computation yielding success or error. +- `choice`: Never-failing computation holding one of several alternatives. +- `just`: Identity computation always yielding a single value. + +Composition operations include `transform` (mapping), `and_then` (sequential monadic binding), `operator&` (simultaneous product composition), and `apply` (multidispatch elimination). + +### Member vs. Pipeline Syntax + +Most operations are exposed in two forms: + +- **Member functions** (e.g., `.transform()`, `.and_then()`, `.apply()`) called directly on a carrier (e.g., `ex.transform(f)`). +- **Pipeline functors** in namespace `fn` (e.g., `fn::transform`, `fn::and_then`) applied via `operator|` (e.g., `ex | fn::transform(f)`). + +Freestanding `fn::apply` acts as a utility (like `std::apply`) to unpack any tuple-like or pack-like structure. + +In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected`, `pack`) when referring to both forms or core vocabulary types generally. + +### Storage Shape vs. Call Shape + +Although different types can behave identically during application, they remain strictly distinct in memory. For example, `pack`, `std::tuple`, and `std::pair` all unpack into the same call shape `f(a, b)` during `apply`, but they are separate C++ types with distinct layouts. Application does not silently convert or unify types on the storage side. + +To illustrate these concepts, the examples in this document use a reusable set of value and error types: +```cpp +struct UserId {}; +struct User {}; +struct FilePath {}; +struct MaximumSize {}; +struct BlockSize {}; + +struct NotANumber {}; +struct OutOfRange {}; +struct Missing {}; +struct IoError {}; +struct BadSyntax {}; +struct UnknownKey {}; +``` + +## 1. Why compose types as well as values? + +In idiomatic C++, error handling usually means picking one application-wide error enumeration, a giant `std::variant`, or throwing exceptions. If a function only ever fails due to one specific error, returning a large application-wide error variant discards the precise bounds of what the function can actually do. + +With `libfn`, the compiler derives an exact, graded error pipeline. Consider parsing, validating, and loading a user: + +```cpp +#include +#include +#include +#include +#include + +auto parse_id(std::string_view) -> fn::expected>; +auto validate(UserId) -> fn::expected>; +auto load(UserId) -> fn::expected>; + +auto graded_pipeline(std::string_view sv) -> void { + auto pipeline = parse_id(sv) + | fn::and_then(validate) + | fn::and_then(load); + + // The exact derived error union is recorded in the type: + static_assert(std::same_as< + decltype(pipeline), + fn::expected> + >); +} +``` + +The resulting `expected` statically records that the pipeline yields a `User` on success, or fails with exactly one of `NotANumber`, `OutOfRange`, `Missing`, or `IoError`. This exact union accumulates automatically via `and_then` composition. + +### What Does "Graded" Mean? + +Standard monads are rigid: an `expected` requires every step in a pipeline to return the identical error type `E`. This forces you to define a monolithic global error union upfront. + +A **graded monad** relaxes this restriction. Each operation is indexed by a "grade"—a set representing its specific possible errors (its "effects"). As you chain operations, the compiler automatically unions these grades. + +The resulting error type is **graded**: it dynamically expands (or narrows during recovery) to match the *exact* subset of errors possible in the compiled path, providing strict static effect tracking (subeffecting) with zero boilerplate. + +> [!TIP] +> +> ### Mathematical note — graded monads +> +> In a standard monad, the bind operation has type $M\langle A\rangle \to (A \to M\langle B\rangle) \to M\langle B\rangle$. In a graded monad, the monadic carrier is indexed by an effect grade from a partially ordered monoid (pomonoid) $(\mathcal{E}, \cup, \emptyset, \subseteq)$. The bind operation has type $M_E\langle A\rangle \to (A \to M_F\langle B\rangle) \to M_{E \cup F}\langle B\rangle$. +> +> This formulation builds directly on functional effect theory - see references in the Further Reading section. + +Simultaneous product composition achieves the same precision for both values and errors. Using `operator&`, you can evaluate independent computations and bundle their results: + +```cpp +#include +#include +#include +#include + +auto product_composition() -> void { + fn::expected> id{}; + fn::expected> user{}; + + auto bundled = id & user; + + static_assert(std::same_as< + decltype(bundled), + fn::expected, fn::copack> + >); +} +``` + +The result contains a `pack` of the successful values and a `copack` of the exact possible errors. + +### The Two Cooperating Mechanisms + +Behind these highly precise compiled types are two independent mechanisms that cooperate to derive and eliminate these shapes: + +1. **Type algebra** records and normalizes the exact stored C++ types using `pack` and `copack` as you compose operations. +2. **The application protocol** uses `apply` and ordinary C++ overload resolution to unpack those stored values and route them to your functions or lambdas. + +To handle multiple alternative paths smoothly inside `apply`, the library provides the `fn::overload` utility. This utility constructs a unified overload set from a collection of otherwise unrelated lambdas, routing the unpacked values to the correct handler at compile time via C++ overload resolution. + +These derived types are the actual explanation of the library's design, not an internal template-metaprogramming implementation detail. Understanding the precise algebraic rules of this type algebra and the mechanics of application is key to mastering the library. + +## 2. Types as an algebra: zero, unit, alternatives, and products + +To derive strict programmatic shapes, `libfn` uses an algebraic vocabulary over types. + +- `copack<>` represents **0** (Zero) - an uninhabited type. +- `pack<>` represents **1** (Unit) - a type with exactly one state. +- `copack` represents **A + B** (Alternatives) - a coproduct where exactly one alternative is present. +- `pack` represents **A × B** (Products) - a type where all fields are present simultaneously. + +These states can also be used to express the standard vocabulary types: + +- `std::optional` ≅ **1 + T** (It is either empty/unit or contains `T`, similar to `copack`) +- `std::expected` ≅ **T + E** (It contains either success `T` or error `E`, similar to `copack>`) + +The symbol ≅ indicates an equivalent state shape (an information-level correspondence), not `std::same_as`. `std::optional` is its own distinct C++ type, but algebraically, it behaves as `1 + T`. + +### Zero is not unit + +In `libfn`'s algebra, zero and unit are strictly separated: + +- `copack<>` is uninhabited. You cannot construct it. Algebraically, it is `0`. +- `pack<>` is the one nullary product value. You can construct it via `pack<>{}`. Algebraically, it is `1`. + +Because `pack<>` exists, applying a callable to it invokes a nullary function. Because `copack<>` is uninhabited, providing a callback over `copack<>` is statically proven to be unreachable code (dead code). + +In C++, `void` is often conflated with empty state, but algebraically, `void` is a unit type `1`, similar to `pack<>`. + +Consider the difference in these carrier states: + +| Computation | Meaning | +| ----------- | ------- | +| `expected>` | An expected value that **cannot fail** because its error state is uninhabited. | +| `expected, E>` | An expected value that **cannot succeed** because its success state is uninhabited. | +| `optional>` | An optional that **must be empty**, as its value state is uninhabited. | +| `expected` | An expected that yields **no value on success**, but can fail with `E`. | + +### Copacks use set semantics + +A major feature of `libfn` is that `copack` forms canonical sets of types, in contrast to the positional indexing of `std::variant`. When you combine types into a coproduct, `fn::copack_for` guarantees deduplication, flattening, and a canonical ordering. + +```cpp +#include +#include + +auto test_copack_set_semantics() -> void { + using SetA = fn::copack; + using SetB = fn::copack; + + // Flattening, deduplication, and reordering happen automatically: + using Union = fn::copack_for>; + + static_assert(std::same_as< + Union, + fn::copack + >); +} +``` + +> [!NOTE] +> +> ### Under the Hood: copack vs. copack_for +> +> In C++, there is no native language feature to represent a "set of types." Template parameter lists are always positional, variadic sequences. Syntactically, this means `copack` and `copack` would be completely distinct types—a property that directly violates the mathematical commutative law of set union. +> +> To enforce strict, mathematically sound set semantics at compile time, `libfn` defines a single, strict canonical representation and actively rejects any instantiation that diverges from it: +> +> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in strict lexicographical order (the order defined by C++26 `std::type_order`, which `libfn` emulates for pre-C++26 compilers). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` lexicographically precedes `B`) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** +> - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and lexicographical sorting automatically, and resolving directly to the validated canonical `copack` type. +> +> To the C++ programmer, they can be used interchangeably because `copack_for` always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized _state shape_, while `copack_for` represents the _construction utility_. Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. + +The laws governing `copack` are: + +- **Commutative**: Order of types does not change the resulting set. +- **Associative**: Nesting copacks is equivalent to flattening them. +- **Idempotent**: Duplicate types are collapsed into one. +- **Identity**: `copack<>` acts as the union unit (adding `copack<>` changes nothing). + +Because canonical ordering collapses identical types, distinct types must never be silently lost if the ordering cannot distinguish them. Consequently, types combined into a `copack` should be strongly typed tag structs or distinct domain objects, not generic primitives whose semantic meaning depends on their position (e.g., `copack` becomes `copack`). + +### The algebra is strictly opt-in + +`libfn` relies on explicit consent. It does not silently reinterpret arbitrary C++ types as products or coproducts: + +- `std::tuple` remains a standard tuple. +- `std::variant` does not acquire `copack` set semantics. +- Tuple-like participation in `apply` changes call shape applicability, but it does not change the stored type identity. +- A plain `expected` does not automatically become graded. + +To invoke the algebra, you use the opt-in mechanisms provided by the library: + +- Direct construction of `pack` and `copack_for`. +- Explicit conversions via `fn::as_pack` and `fn::as_copack`. +- Member helpers for explicit type lifting (detailed in Section 7). + +If a side is already a `copack` or `pack`, forwarding it behaves naturally without nesting. + +When a callable supplied to `and_then` needs to produce an error grade, it can explicitly lift an expected value into one with a copack error: + +- A callback returning `expected>` selects `copack` as the exact result spelling. +- It does **not** authorize a union with a completely different, unrelated plain error type. Union grading requires the original outer `expected`'s error side to also be a `copack`. + +> [!TIP] +> +> ### Mathematical note — products and coproducts +> +> In category theory, a **product** ($A \times B$) is equipped with projection morphisms extracting $A$ or $B$. A **coproduct** ($A + B$) is equipped with injection morphisms placing $A$ or $B$ into the coproduct. The nullary product is the terminal object (Unit), and the nullary coproduct is the initial object (Zero). +> +> In `libfn`, `pack` and `copack` correspond strictly to these programming shapes. However, they do not establish a complete, formal category of all C++ types; they define the specific algebraic domain inside `libfn`. +> +> +## 3. The vocabulary types + +### pack: all fields are present + +A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: attempting to nest a `pack` inside another `pack` via `append` flattens them, as a flat pack is canonical. + +```cpp +#include +#include + +auto test_pack() -> void { + fn::pack p{UserId{}, User{}}; // CTAD + + auto [id, user] = p; // Structured bindings work naturally + + // Ordered, non-deduplicated fields + using P = fn::pack; + static_assert(std::same_as()), UserId&>); + + // Splicing scalars or other packs via append: + auto row = fn::pack{UserId{}}.append(FilePath{}); + auto wider = std::move(row).append(fn::pack{true, 3}); + + static_assert(std::same_as< + decltype(wider), + fn::pack + >); +} +``` + +### copack: one exact alternative is present + +A `copack` stores exactly one of its defined alternatives. Consider a system processing different kinds of lexer tokens or configuration values. + +When you evaluate a `copack` via its member `apply` function, it expands one selected outer level only. The active alternative currently stored inside the coproduct is passed as a terminal argument to your callback, rather than recursively unpacking any internal structures. + +```cpp +#include +#include +#include + +struct IntegerToken {}; +struct StringToken {}; + +auto test_copack() -> void { + constexpr fn::copack token = IntegerToken{}; + + // Member apply eliminates the copack by routing the active alternative to an overload set: + constexpr auto value = token.apply(fn::overload{ + [](IntegerToken) { return 1; }, + [](StringToken) { return 2; } + }); + + static_assert(value == 1); +} +``` + +A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. + +### The computation carriers + +To model computation, `libfn` uses carrier types: + +- `just`: Always contains a single successful value. +- `optional`: Contains a value or is empty. +- `expected`: Contains a value or an exact error type. +- `choice`: Always contains one of several selected alternatives, representing the complete state space of the computation. + +Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. + +**Rule:** A carrier does not need another carrier for multidispatch. Inside an `expected` or `optional`, store your alternative states as `copack`. Use `choice` only when those alternatives are themselves the outer, never-failing computation. + +A programmer might be tempted to represent a never-failing, multi-alternative computation by nesting a coproduct inside an identity carrier, spelling it `just>`. In `libfn`'s type algebra, this is precisely the space filled by `choice`. Structurally, `choice` is equivalent to a `just` container of a `copack`, providing a single-layer monadic carrier that represents a never-failing computation over a coproduct. + +In fact, attempting to instantiate `just>` will trigger a compile-time static assertion failure inside `just`, explicitly warning the programmer: `"a just over a copack is spelled choice"`. + +These carriers impose constraints on their payloads, but reference payloads are broadly supported where sound. For example, `optional` is supported and well-defined. + +## 4. Mapping values and errors + +Mapping allows you to change the contained data without altering the structural success/failure shape of the computation. `libfn` uses `transform` (functor map) to operate on the successful channel, and `transform_error` for the error channel. + +```cpp +#include +#include +#include +#include +#include +#include + +auto mapping_values_and_errors() -> void { + fn::expected> ex{}; + + auto mapped_val = ex | fn::transform([](UserId) { return User{}; }); + static_assert(std::same_as< + decltype(mapped_val), + fn::expected> + >); + + auto mapped_err = ex | fn::transform_error(fn::overload{ + [](Missing) { return BadSyntax{}; }, + [](IoError e) { return e; } + }); + + static_assert(std::same_as< + decltype(mapped_err), + fn::expected> + >); +} +``` + +Key principles of mapping: + +- `transform` stays strictly within the carrier type. +- Success and error states are rigidly preserved. +- A bare `copack` also has `transform`, allowing mapping across alternatives. +- Heterogeneous branch results inside `transform_error` form a normalized result `copack`. +- Applying an error-side operation like `transform_error` to a carrier that has no error side (like `just` or `choice`) is rejected by the compiler. +- If a side is uninhabited (`copack<>`), the transformation is well-formed, but vacuous (i.e., a no-op): + - `transform_error` on `expected>` is proven unreachable and a no-op. + - `transform` on `optional>` is proven unreachable and a no-op. + +> [!TIP] +> +> ### Mathematical note — functors +> +> In categorical terms, a functor maps types and functions: +> $f : A \to B$ +> $map(f) : F\langle A\rangle \to F\langle B\rangle$ +> +> `libfn` calls $map$ `transform`. A functor must preserve identity and composition. See Section 12 for how these laws translate to C++. +> +## 5. Product composition with operator& + +Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results. + +```cpp +#include +#include +#include +#include + +auto operator_and_composition() -> void { + fn::expected> a{}; + fn::expected> b{}; + + auto result = a & b; + + static_assert(std::same_as< + decltype(result), + fn::expected, fn::copack> + >); +} +``` + +The runtime failure semantics are exact: + +- The result type statically records all possible errors. +- At runtime, the result stores at most _one_ error, not an accumulated collection of errors. +- If both operands already contain errors, standard short-circuit evaluation applies (the left error is retained). +- Normal C++ evaluation rules apply: `operator&` does not magically make I/O lazy or parallel. + +When composing two `copack`s directly, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s. The variadic entry point into these rules is `fn::identity(...)`. Note that bare `scalar & scalar` is not syntactically valid by itself; you must lift them with `fn::identity(a, b)` or `fn::as_pack(a) & b`. + +```cpp +#include +#include +#include + +struct A {}; +struct B {}; +struct C {}; +struct D {}; + +auto test_cartesian_distribution() -> void { + constexpr fn::copack ab = A{}; + constexpr fn::copack cd = C{}; + + // The product distributes over the coproduct: (A + B) x (C + D) -> AC + AD + BC + BD + auto result1 = ab & cd; + static_assert(std::same_as< + decltype(result1), + fn::copack_for, fn::pack, fn::pack, fn::pack> + >); + + // The distributive law also works on a pack: (A × B) × (C + D) = ABC + ABD + constexpr fn::pack Pab = {A{}, B{}}; + auto result2 = Pab & cd; + static_assert(std::same_as< + decltype(result2), + fn::copack_for, fn::pack> + >); +} +``` + +> [!TIP] +> +> ### Mathematical note — monoidal composition +> +> Product composition relies on monoidal properties: +> +> - **Associativity**: Holds after canonical `pack` flattening. +> - **Unit**: `pack<>` acts as the product unit. +> - **Distribution**: Products distribute over coproducts. +> - **Union**: For `expected`, the error grades form a union. +> +## 6. Sequential composition with and_then + +Sequential composition chains dependent operations where the success of one feeds the input of the next. In `libfn`, this is achieved using `and_then` (monadic bind). + +A monadic carrier wraps a value. A _Kleisli arrow_ is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind. `and_then(f)` produces a storable operation value that can be piped. + +```cpp +#include +#include +#include +#include + +auto parse() -> fn::expected>; +auto load(UserId) -> fn::expected>; + +auto sequential_bind() -> void { + auto result = parse() | fn::and_then(load); + + static_assert(std::same_as< + decltype(result), + fn::expected> + >); +} +``` + +The strict "same-kind" contract defines how types interact: + +- An `optional` binds to an `optional`. +- A plain `expected` binds to an `expected`, retaining its exact plain error type. +- A copack-graded `expected` can union heterogeneous error sets (as demonstrated above). +- A copack-valued input can join heterogeneous successful branch types into a normalized `copack`. +- Exact branch convergence preserves the exact type without creating duplicate union states. +- All-`void` branches join cleanly to `void`, but mixed void/non-void branches are rejected. +- A bare callback result belongs to `transform`, not `and_then`. + +The library formalizes this "same-kind" boundary via the `fn::same_kind` concept, which lets generic templates probe whether two carrier types belong to the same monadic family: + +```cpp +#include +#include +#include +#include + +static_assert(fn::same_kind, fn::optional>); +static_assert(fn::same_kind, fn::expected>); +static_assert(!fn::same_kind, fn::expected>); +``` + +> [!TIP] +> +> ### Mathematical note — monads +> +> A monad is defined by a binding operation: +> $bind : M\langle A\rangle \to (A \to M\langle B\rangle) \to M\langle B\rangle$ +> +> A proper monad provides `pure` (or `return`) for wrapping a value, `bind` for chaining, and satisfies three monad laws: left identity, right identity, and associativity. +> +## 7. Graded expected: exact error sets + +`expected` grading provides exactly bounded error sets. When an outer computation holds a coproduct of successful values, and each value requires a different operation to proceed, `libfn` derives a single, normalized `expected` shape. + +Consider a configuration reader that parses a loosely typed file into specific valid structural alternatives: `MaximumSize`, `FilePath`, or `BlockSize`. + +```cpp +#include +#include +#include +#include +#include + +auto read_config() -> fn::expected< + fn::copack_for, + fn::copack_for +>; + +auto config_pipeline() -> void { + auto validated = read_config() | fn::and_then(fn::overload{ + [](MaximumSize v) { return fn::expected>{v}; }, + [](FilePath v) { return fn::expected>{v}; }, + [](BlockSize v) { return fn::expected>{v}; } + }); + + // The result exactly bounds both the successful paths and the error paths + static_assert(std::same_as< + decltype(validated), + fn::expected< + fn::copack, + fn::copack + > + >); +} +``` + +Two independent joins occurred during `and_then`: + +1. The successful branch values formed the normalized value copack. +2. The existing outer errors (`BadSyntax`, `UnknownKey`) and the new branch errors (`OutOfRange`, `Missing`) formed the normalized error copack. + +This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` requires the exact same error type `E` to participate in monadic bind (meaning `expected` and `expected` are **not** `same_kind`), any two graded `expected` types are considered `same_kind`, regardless of how their individual error sets differ: + +```cpp +#include +#include +#include +#include + +static_assert(fn::same_kind< + fn::expected>, + fn::expected> +>); +``` + +It is crucial to distinguish value joining from error grading: + +- You can join branch values while retaining a plain (non-copack) error: `expected, E>`. +- Differing _plain_ errors across branches are completely rejected. +- A `copack` on the error side is the strict opt-in to error-set unioning. + +To make composition more user-friendly, `libfn` allows explicit **type promotion** during sequential composition: +- In `and_then` (success binding), a plain error type `E` is automatically promoted to `copack` if the returning error type of the callback is `copack`. +- In `or_else` (recovery/error binding), a plain success type `T` is automatically promoted to `copack` if the returning success type of the callback is `copack`. + +This ensures that you can smoothly transition from a simple, un-graded computation to a graded, multi-alternative computation when entering a pipeline step that introduces alternative paths, without needing to manually wrap or lift your starting types. + +If you need to perform this promotion explicitly on the carrier itself before entering a composition, `libfn` provides direct, zero-cost member helpers: +- `.copack_error()` on `expected` explicitly lifts the error, transforming `expected` to `expected>`. +- `.copack_value()` on `expected` explicitly lifts the success value, transforming `expected` to `expected, E>`. +- `.copack_value()` on `optional` symmetrically lifts the value, transforming `optional` to `optional>`. + +These helper methods provide a compact, explicit alternative to implicit pipeline promotions: + +```cpp +#include +#include +#include +#include + +auto test_explicit_lifting(fn::expected result, fn::optional opt) -> void { + // Explicitly lift the error side of expected: + auto graded_err = std::move(result).copack_error(); + static_assert(std::same_as< + decltype(graded_err), + fn::expected> + >); + + // Explicitly lift the value side of expected: + auto graded_val = std::move(result).copack_value(); + static_assert(std::same_as< + decltype(graded_val), + fn::expected, IoError> + >); + + // Explicitly lift the value side of optional: + auto graded_opt = std::move(opt).copack_value(); + static_assert(std::same_as< + decltype(graded_opt), + fn::optional> + >); +} +``` + +Recovery via `or_else` behaves symmetrically. It handles input error alternatives and joins any new errors produced by the recovery branches while preserving the already-successful value path. Heterogeneous recovery values require a suitable copack-valued input. Any original error handled by a branch does not automatically remain possible unless a branch explicitly returns it again. + +### Widening is subeffecting + +In accordance with the subeffecting principles of graded monads (Section 1), a narrow error set can be safely widened during composition, but narrowing requires explicit mitigation. Implicit narrowing (without handling the removed errors) is unsafe and rejected by the compiler. However, you can **safely narrow or collapse** an error grade at any point by explicitly handling and mapping the errors using `transform_error`. Because `transform_error` on a graded `expected` forces exhaustive matching over all possible alternatives, you can map multiple diverse error types into a single common error type (or a narrower `copack`), safely reducing the static error grade of your pipeline. + +The bottom error grade is `copack<>`: + +```cpp +fn::expected> cannot_fail{}; +``` + +This computation cannot fail, but it is algebraically prepared to widen if later composition introduces possible errors. + +A concrete example of this is `expected>`. Because `void` represents the unit `1` and `copack<>` represents the zero `0`, this type maps algebraically to $1 + 0 \cong 1$. Having a cardinality of exactly one, it has no possible errors, can never fail, and can only succeed with a single empty trigger (`void`). This makes it structurally isomorphic to the **Unit type**. + +In practice, `expected>` acts as **the graded gateway** to start your pipelines. By initiating a chain with this unit trigger, you seamlessly opt-in all subsequent `and_then` bindings into graded error-set unioning, without having to invent any fake starting errors or manually wrap your initial steps. Since its starting error set is empty (`copack<>`), unioning it with subsequent steps' errors (say, `copack`) yields exactly those errors. + +> [!TIP] +> +> ### Mathematical note — the error pomonoid +> +> A graded monad indexes operations over a monoid (or pomonoid). Here, the grades are finite sets of types, with set union as the binary operation, the empty set as the unit, and subset relation as the partial order. +> +> Binding looks like this: +> $M_{E}\langle A\rangle \to (A \to M_{F}\langle B\rangle) \to M_{E \cup F}\langle B\rangle$ +> +> Widening an error set corresponds to effect approximation (subeffecting). For formal validation, `libfn`'s graded monad can be interpreted as a lax monoidal functor ($G : \mathcal{M} \to [\mathcal{C}, \mathcal{C}]$) as defined in Orchard, Wadler, and Eades, _Unifying graded and parameterised monads_ (Definition 21). The type `expected>` represents the **monadic unit** of this graded monad ($\eta : A \to M_{\emptyset}\langle A\rangle$), operating at the neutral identity element $\emptyset$ of the error pomonoid. Be aware that `libfn` is practical C++ software; it enforces type derivations but does not magically prove naturality or coherence laws for arbitrary user-defined closures. +> +## 8. The identity cluster + +Certain operations behave like an identity functor across different carriers. Because some states correspond structurally, `libfn` licenses specific cross-carrier behavior to prevent redundant boilerplate. + +Consider this cross-carrier table: + +| Carrier | Algebraic State Shape | +| - | - | +| `just` | **T** (A single value) | +| `choice` | **Ts...** (A coproduct of values) | +| `expected>` | **T + 0** ≅ **T** (A value and an uninhabited error) | + +These three computation carriers have canonically isomorphic state shapes—they all guarantee a successful value of some type. + +Because they are equivalent, `libfn` provides a licensed pipeline operation that allows binding across these boundaries: + +```cpp +#include +#include +#include +#include + +auto test_identity_cross() -> void { + fn::just j{UserId{}}; + + // Cross-carrier pipeline bind + auto result = j | fn::and_then([](UserId u) { + return fn::expected>{u}; + }); + + static_assert(std::same_as>>); +} +``` + +The bind operation adopts the carrier family of the provided callback. However, the member `and_then` remains strict to its own carrier family. Only the pipeline `operator|` acts as the licensed cross-carrier operation. + +Furthermore, fallible types like `expected` (with inhabited error states) and `optional` cannot indiscriminately switch to other carriers, because doing so would risk silently discarding an inhabited state. + +Monadic operations behave naturally around this identity cluster: + +- `inspect` and `discard` remain meaningful. +- Dead-side operations like `inspect_error` or `transform_error` reject `just` and `choice`, and act vacuously on `expected>`. +- `fail` and `filter` reject the identity cluster entirely, because no failure state can possibly be constructed from an identity carrier. + +> [!TIP] +> +> ### Mathematical note — the bottom grade and canonical state shapes +> +> The structural equivalence of `just`, `expected>`, and `choice` over coproducts is an information-level correspondence. `libfn` realizes this correspondence through licensed binds, not through implicit C++ implicit conversions. +> +## 9. choice: identity over a coproduct + +The `choice` carrier represents a computation that always succeeds by selecting one of several alternatives. Structurally, it serves as the single-layer carrier for coproduct states, avoiding the invalid nested `just>` representation discussed in Section 3. + +### Decoupling via Pipeline Functors + +As established in Section 8, transitions within the identity cluster are strictly restricted to pipeline-scoped functors to preserve decoupling between carriers. + +For example, a pipeline-scoped `fn::transform` on a `just` that returns a `copack` is promoted automatically to a `choice`: + +```cpp +#include +#include +#include +#include +#include + +auto test_identity_transformation() -> void { + fn::just j{UserId{}}; + + // Transforming a just with a callable returning a copack produces a choice + auto mapped = j | fn::transform([](UserId) { + return fn::copack_for{Missing{}}; + }); + + static_assert(std::same_as>); +} +``` + +Similarly, a pipeline-scoped `fn::and_then` on a `just` is permitted to return a `choice` or `expected>` directly. + +Inside its own carrier domain, `choice` behaves differently from a bare `copack` in how it maps and binds: + +- A `copack` is plain data. +- A `choice` is a never-failing outer computation over those selected alternatives. Every alternative must be handled. + +Consider a scenario where different branches of a switch return different `choice` types: + +```cpp +#include +#include +#include +#include +#include +#include + +auto test_choice_mapping() -> void { + fn::choice_for ch{UserId{}}; + + // transform nests the returned choice as a mapped value + auto mapped = ch | fn::transform(fn::overload{ + [](UserId) { return fn::choice{Missing{}}; }, + [](User) { return fn::choice{FilePath{}}; } + }); + + static_assert(std::same_as< + decltype(mapped), + fn::choice_for, fn::choice> + >); + + // and_then joins and flattens them into a normalized superset choice + auto bound = ch | fn::and_then(fn::overload{ + [](UserId) { return fn::choice{Missing{}}; }, + [](User) { return fn::choice{FilePath{}}; } + }); + + static_assert(std::same_as< + decltype(bound), + fn::choice + >); +} +``` + +Bare-value callbacks are rejected by `choice`'s `and_then`. + +> [!TIP] +> +> ### Mathematical note — map versus join +> +> The distinct behavior of `and_then` is formally explained by the $join$ operation: +> $join : M\langle M\langle A\rangle\rangle \to M\langle A\rangle$ +> $bind(x, f) = join(map(x, f))$ +> +> For `choice`, $join$ is normalized coproduct union. The phrase "identity over a coproduct" is a statement about effect and state shape, not a formally proved C++ endofunctor across arbitrary types. +> +> +## 10. Elimination and multidispatch + +Once your computation shapes are fully derived, you must eliminate the structure to yield an ordinary C++ value. This is done via `apply` or `apply_r`. + +It is vital to distinguish `transform` from `apply`: + +- `transform` stays _inside_ the carrier or copack, producing a new carried type. +- `apply` _eliminates_ the structure entirely, requiring all branches to converge on one deduced result type. +- `apply_r` permits branch results acceptable as the specific type `R`. + +Application expands one selected level only. The call shapes are straightforward: + +| Type | Eliminated Call Shape | +| - | - | +| `A` | `f(A)` | +| `pack` | `f(A, B)` | +| `pack<>` | `f()` | +| `std::tuple` | `f(A, B)` | +| `copack` | `f(A)` or `f(B)` | +| `copack, C>` | `f(A, B)` or `f(C)` | + +A whole-carrier `expected` application cleanly handles both success and error paths into one result type: + +```cpp +#include +#include +#include // for fn::overload + +auto test_elimination(fn::expected> ex) -> int { + return ex.apply(fn::overload{ + [](UserId) { return 1; }, + [](Missing) { return 0; } + }); +} +``` + +Exhaustiveness is statically constrained. If you omit a handler for a possible type, the compilation fails. `fn::overload` is merely a helper; final selection always relies on ordinary C++ overload resolution. + +### Type-tagged elimination + +Because multiple structures can share the same unpacking call shape (e.g., `pack` and `std::tuple` both call `f(a, b)`), untagged `apply` can sometimes erase the structural context of the state. To preserve this context and prevent permissive C++ implicit conversions from accidentally conflating different states, `libfn` provides the **`apply_type`** (and `apply_type_r`) member functions. + +When you eliminate a carrier using `apply_type`, the active handler receives an explicit C++ state tag or constructor tag as its first argument, followed by the unpacked payload: + +- On `expected`, the success arm receives `std::in_place` followed by the success value, while the error arm receives `fn::unexpect` followed by the error. +- On `optional`, the success arm receives `std::in_place` followed by the value, while the empty arm receives `std::nullopt`. +- On `copack` and `choice`, the active alternative arm receives `std::in_place_type` followed by the payload. +- On `just` and `pack`, the active arm receives `std::in_place_type` (or `std::in_place_type` for empty/nullary states). + +> [!TIP] +> +> ### Mathematical note — elimination +> +> Eliminating a coproduct means supplying a function for every single injection. Eliminating a product means supplying all its components. `apply_type` ensures the origin tag of the injection is retained during the call. +> +## 11. The monadic operations map + +This is a concise reference for `libfn`'s operations, organized by channel and effect: + +**Success Channel** +- `transform`: Maps the successful value. Stays inside the carrier. +- `and_then`: Sequences computations. The mechanism for introducing new errors into a graded expected. +- `filter`: Enters a short-circuit state if a predicate fails. Does not widen error grades. +- `inspect`: Observes the successful value transparently. + +**Error/Empty Channel** +- `transform_error`: Maps the error value. Stays inside the carrier. +- `or_else`: Sequences computations based on errors. Joins recovery values. +- `recover`: Same as `or_else`, but always wraps raw values back into a success state. +- `inspect_error`: Observes the error value transparently. +- `value_or`: Eliminates the carrier by supplying a fallback value on failure. +- `fail`: Short-circuits success into a forced failure state. Does not widen error grades. + +**Neutral** + +- `discard`: Unconditionally evaluates the carrier, discards the result, and returns `void`. This is used to signal to the compiler that the return value is deliberately ignored. + +### Key Architectural Rules of the Map + +To reason about how these operations affect the type algebra of your computation: + +- **Graded `and_then`** is the primary mechanism for introducing a _new_ error type (widening the error grade) into your pipeline. +- **`filter` and `fail`** merely enter an _existing_ short-circuit state. They do not widen the error grade (the type must already be capable of holding the failure state). +- **Error-side monadic operations** (like `transform_error`, `or_else`, `recover`, and `inspect_error`) are only well-formed if the carrier has an appropriate error or empty side (and are rejected on identity carriers like `just` or `choice`). + + +## 12. Laws as C++ equalities + +The algebraic laws governing `libfn` shapes are verified by the compiler where structural capabilities permit. For instance, you can observe functor identity and monad left identity in `constexpr` contexts: + +```cpp +#include +#include +#include +#include +#include + +constexpr auto test_laws() -> void { + fn::expected> ex{42}; + + // Functor Identity: mapping with identity yields the same value + auto id = [](auto v) { return v; }; + static_assert((ex | fn::transform(id)) == ex); + + // Monad Left Identity: pure(x) >>= f is equivalent to f(x) + auto pure = [](int v) { return fn::expected>{v}; }; + auto f = [](int v) { return fn::expected>{v * 2}; }; + static_assert((pure(42) | fn::and_then(f)) == f(42)); +} +``` + +Other properties hold structurally: +- **Functor composition**: `transform(f) | transform(g)` equals `transform(g(f(x)))`. +- **Monad right identity**: `m | and_then(pure)` equals `m`. +- **Monad associativity**: `(m | and_then(f)) | and_then(g)` equals `m | and_then(\x -> f(x) | and_then(g))`. For graded expected, both sides of the associativity derive the exact same normalized union grade. +- **Product associativity**: Holds after canonical `pack` normalization. +- **Coproduct set semantics**: Union associativity, commutativity, and idempotence apply. +- **Coherent widening**: Upcasting an error through intermediate supersets yields the same final type as upcasting directly to the broadest superset. +- **Identity cluster binds**: Laws hold across `just`, `choice`, and `expected>` via the canonical payload-preserving state-shape correspondence. + + +## 13. C++ mechanics that preserve the algebra + +To make the algebraic model reliable in everyday C++, `libfn` uses extensive compiler mechanisms to reject malformed usage and preserve performance properties. + +### Constraints and exhaustiveness + +Public concepts and `requires` clauses enforce correctness before instantiation. Operations are protected by applicability concepts (negative probes) that proactively reject impossible calls. This underpins the compile-time exhaustiveness guarantees of `apply` and monadic operations established in Sections 3 and 10, catching unhandled alternatives at the boundary of instantiation rather than deep inside template machinery. + +### C++ value properties + +`libfn` thoroughly respects C++ value mechanics: +- Core operations are fully `constexpr`. +- Types are structural if their elements permit (allowing them as non-type template parameters). +- `noexcept` is conditionally computed based on the operations provided. +- Value categories (lvalue/rvalue) propagate strictly to callbacks, avoiding unnecessary copies. +- Immovable and move-only payloads are fully supported in place. +- Reference-bearing packs and `optional` are deliberately supported. Lifetime responsibility for non-owning references remains with the caller. + +```cpp +#include +#include + +auto test_references() -> void { + int x = 42; + fn::optional opt{x}; + static_assert(std::same_as); +} +``` + +### pfn and fn + +The library is divided into layers: + +- `pfn` (Polyfill fn) is the standards-facing layer. It provides polyfills of `std::optional` and `std::expected`, conforming to standard C++26 (and later) shapes. +- `fn` is the strict extension layer. It introduces the `pack`/`copack` algebra, multidispatch, graded errors, `choice`, `just`, and the cross-carrier pipeline monadic operation (`operator|`). + +## Functional terminology + +For readers with a background in functional languages (like Haskell or OCaml), this table translates standard terminology to `libfn`'s C++ vocabulary: + +| Functional Term | `libfn` Equivalent | +| --------------- | ------------------ | +| `fmap` / `map` | `transform` / `transform_error` | +| `bind` / `>>=` | `and_then` | +| `pure` / `return` | Constructor / `just` / Factory functions | +| Kleisli arrow | The callable passed to `and_then` | +| Product type | `pack` / `std::tuple` | +| Coproduct / Sum | `copack` / `choice` | +| Subeffecting | Widening an error grade / subset inclusion | + +## Further reading + +For formal validation of the algebraic structures modeled in `libfn`, refer to: + +1. Orchard and Petricek, [“Embedding effect systems in Haskell”](https://www.doc.ic.ac.uk/~dorchard/publ/haskell14-effects.pdf) (for effect sets, union, and subeffecting). +2. Orchard, Wadler, and Eades, [“Unifying graded and parameterised monads”](https://arxiv.org/pdf/2001.10274) specifically Definition 21 (for the graded-monad interpretation). +3. McDermott and Uustalu, [“Flexibly Graded Monads and Graded Algebras”](https://dylanm.org/flexibly-graded-monads.pdf) _Note: `libfn` does not claim to fully implement their flexibly graded construction, but the work contextualizes graded structures._ From 26d0a07e0ac1ba42659ecf8f9135c167ef558d6a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 12:25:13 +0100 Subject: [PATCH 02/51] docs: Update and polish TYPE_ALGEBRA.md and README.md * Document simultaneous disjunction and conjunction's identity cluster support * Fix pack get projection ADL syntax and Section 9 cross-carrier constraints * Add singular lift and get extraction documentation to Section 3 * Elevate all mathematical notes to category-theoretically rigorous style * Eliminate duplicate graded monad definitions and fix apply_type carrier list Assisted-by: Gemini:gemini-3.6-flash --- README.md | 2 +- TYPE_ALGEBRA.md | 332 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 276 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 4fc9afd5..bc799e17 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ The example also demonstrates how well libfn works with general programming idio These properties also make libfn a natural fit for asynchronous composition, such as coroutines or senders/receivers. Operations and monadic types alike are plain values: `and_then(f)` is a *description* of a step, executed only when a monad is piped into it (an input to the sequence, or the result of the preceding operation). A framework can hold the steps of a computation and apply them as results arrive, with a strongly typed error channel and no hidden control flow — exactly what such programming models need. -Beyond the example: `fn::choice` (a monad over `fn::copack`); the same operations over `fn::optional` as over `fn::expected`; tuple protocol in `fn::pack` (`get(p)` or structured bindings); `fn::pack` and `fn::copack` are both structural types (a `constexpr` value which may be used as a template parameter); support for immovable values and callables; and more — see [examples/](examples/) and the [API reference][docs]. +Beyond the example: `fn::choice` (a monad over `fn::copack`); the same operations over `fn::optional` as over `fn::expected`; simultaneous disjunction (using `operator|` to fallback-combine monadic computations) and its `fn::disjoin` fold; `fn::conjoin` for simultaneous product folds; tuple protocol in `fn::pack` (`get(p)` or structured bindings); `fn::pack` and `fn::copack` are both structural types (a `constexpr` value which may be used as a template parameter); support for immovable values and callables; and more — see [examples/](examples/) and the [API reference][docs]. ## How diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 90e04065..9ffa4c87 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -88,9 +88,20 @@ The resulting error type is **graded**: it dynamically expands (or narrows durin > > ### Mathematical note — graded monads > -> In a standard monad, the bind operation has type $M\langle A\rangle \to (A \to M\langle B\rangle) \to M\langle B\rangle$. In a graded monad, the monadic carrier is indexed by an effect grade from a partially ordered monoid (pomonoid) $(\mathcal{E}, \cup, \emptyset, \subseteq)$. The bind operation has type $M_E\langle A\rangle \to (A \to M_F\langle B\rangle) \to M_{E \cup F}\langle B\rangle$. +> Formally, a **graded monad** (also known as an effect monad) indexes a family of monadic carriers over a partially ordered monoid (pomonoid) of effects $(\mathcal{E}, \bullet, I, \le)$. > -> This formulation builds directly on functional effect theory - see references in the Further Reading section. +> In `libfn`, this pomonoid is defined over the category of finite sets of C++ types: +> +> - **Grades ($\mathcal{E}$)**: Finite sets of alternative types (errors). +> - **Monoidal multiplication ($\bullet$)**: Set union ($\cup$), representing effect accumulation. +> - **Identity ($I$)**: The empty set ($\emptyset$), representing the zero-error/never-failing state. +> - **Partial order ($\le$)**: Subset relation ($\subseteq$), which licenses effect approximation (subeffecting / widening). +> +> For a standard monad $M$, the binding operation maps $M\langle A\rangle \to (A \to M\langle B\rangle) \to M\langle B\rangle$. In `libfn`'s graded monad, bind accumulates effects across the pomonoid: +> +> $$bind : M_E\langle A\rangle \to (A \to M_F\langle B\rangle) \to M_{E \cup F}\langle B\rangle$$ +> +> This formulation enables strict static effect tracking. A lax monoidal functor maps this pomonoid $\mathcal{E}$ into the endofunctor category $[\mathcal{C}, \mathcal{C}]$, formalizing how C++ type derivations trace exact computational side effects. Simultaneous product composition achieves the same precision for both values and errors. Using `operator&`, you can evaluate independent computations and bundle their results: @@ -195,7 +206,7 @@ auto test_copack_set_semantics() -> void { > - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in strict lexicographical order (the order defined by C++26 `std::type_order`, which `libfn` emulates for pre-C++26 compilers). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` lexicographically precedes `B`) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** > - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and lexicographical sorting automatically, and resolving directly to the validated canonical `copack` type. > -> To the C++ programmer, they can be used interchangeably because `copack_for` always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized _state shape_, while `copack_for` represents the _construction utility_. Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. +> To the C++ programmer, they can be used interchangeably because `copack_for` always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. The laws governing `copack` are: @@ -219,7 +230,7 @@ To invoke the algebra, you use the opt-in mechanisms provided by the library: - Direct construction of `pack` and `copack_for`. - Explicit conversions via `fn::as_pack` and `fn::as_copack`. -- Member helpers for explicit type lifting (detailed in Section 7). +- Member helpers for explicit type lifting (detailed in Section 8). If a side is already a `copack` or `pack`, forwarding it behaves naturally without nesting. @@ -232,9 +243,16 @@ When a callable supplied to `and_then` needs to produce an error grade, it can e > > ### Mathematical note — products and coproducts > -> In category theory, a **product** ($A \times B$) is equipped with projection morphisms extracting $A$ or $B$. A **coproduct** ($A + B$) is equipped with injection morphisms placing $A$ or $B$ into the coproduct. The nullary product is the terminal object (Unit), and the nullary coproduct is the initial object (Zero). +> In category theory, the **product** ($A \times B$) is the limit of a diagram of two objects, equipped with projection morphisms ($\pi_1 : A \times B \to A$, $\pi_2 : A \times B \to B$). It satisfies the universal property that any pair of morphisms from an object $X$ to $A$ and $B$ factors uniquely through $A \times B$. +> +> The **coproduct** ($A + B$) is the dual colimit, equipped with injection morphisms ($\iota_1 : A \to A + B$, $\iota_2 : B \to A + B$). Its universal property states that any pair of morphisms from $A$ and $B$ to an object $Y$ factors uniquely through $A + B$. In programming, this unique factoring morphism $[f, g] : A + B \to Y$ is exactly an **overload set** (or callback) mapped over the sum—which `libfn` implements via `apply` and multidispatch. +> +> Nullary structures define the monoidal units: +> +> - The **nullary product** is the terminal object $1$ (Unit), mapped to `pack<>` or C++ `void`. +> - The **nullary coproduct** is the initial object $0$ (Zero), mapped to the uninhabited `copack<>` (with no injection morphisms). > -> In `libfn`, `pack` and `copack` correspond strictly to these programming shapes. However, they do not establish a complete, formal category of all C++ types; they define the specific algebraic domain inside `libfn`. +> C++ types do not form a strict category due to compiler-specific equivalence relations, but `libfn` emulates these properties by enforcing type-level canonical flattening and deduplication. > > ## 3. The vocabulary types @@ -254,7 +272,10 @@ auto test_pack() -> void { // Ordered, non-deduplicated fields using P = fn::pack; - static_assert(std::same_as()), UserId&>); + + // Found via ADL (like std::get) + using std::get; + static_assert(std::same_as(p)), UserId&>); // Splicing scalars or other packs via append: auto row = fn::pack{UserId{}}.append(FilePath{}); @@ -267,6 +288,20 @@ auto test_pack() -> void { } ``` +#### Singular lift with fn::as_pack + +To explicitly lift a single scalar value into a `pack`, use `fn::as_pack(value)`. This is particularly useful when conjoining a scalar with another pack or copack: + +```cpp +#include +#include + +auto test_as_pack(int x = 42) -> void { + auto p = fn::as_pack(x); + static_assert(std::same_as>); +} +``` + ### copack: one exact alternative is present A `copack` stores exactly one of its defined alternatives. Consider a system processing different kinds of lexer tokens or configuration values. @@ -296,6 +331,31 @@ auto test_copack() -> void { A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. +#### Singular lift with fn::as_copack + +To explicitly lift a single scalar value into a `copack` (creating a single-alternative coproduct), use `fn::as_copack(value)`. This is the primary mechanism to manually lift a raw type into a graded state: + +```cpp +#include +#include + +auto test_as_copack(int x = 42) -> void { + auto cp = fn::as_copack(x); + static_assert(std::same_as>); +} +``` + +When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL): + +```cpp +auto extract_singular(fn::copack cp) -> int { + using std::get; + return get(cp); // Has the exact same reference-propagating semantics as fn::apply / .apply +} +``` + +This SFINAE-clean accessor is strictly constrained and disallowed for multi-alternative `copack` types, guaranteeing that compile-time exhaustiveness cannot be bypassed. + ### The computation carriers To model computation, `libfn` uses carrier types: @@ -363,13 +423,14 @@ Key principles of mapping: > > ### Mathematical note — functors > -> In categorical terms, a functor maps types and functions: -> $f : A \to B$ -> $map(f) : F\langle A\rangle \to F\langle B\rangle$ +> A covariant functor $F : \mathcal{C} \to \mathcal{D}$ maps objects $A \in \mathcal{C}$ to $F(A) \in \mathcal{D}$ and morphisms $(f : A \to B)$ to $(F(f) : F(A) \to F(B))$. It must satisfy the functor laws: > -> `libfn` calls $map$ `transform`. A functor must preserve identity and composition. See Section 12 for how these laws translate to C++. +> - **Identity**: $F(id_A) = id_{F(A)}$ +> - **Composition**: $F(g \circ f) = F(g) \circ F(f)$ > -## 5. Product composition with operator& +> In `libfn`, `transform` implements this morphism mapping ($fmap$). Functorial action on the initial object $0$ (the uninhabited `copack<>`) is vacuous: since there are no morphisms originating from $0$ (except the unique initial morphism), mapping over an empty alternative set is vacuously true. The compiler leverages this by optimizing `transform` on `optional>` into a static no-op. +> +## 5. Product composition with operator& (conjunction) Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results. @@ -395,11 +456,11 @@ auto operator_and_composition() -> void { The runtime failure semantics are exact: - The result type statically records all possible errors. -- At runtime, the result stores at most _one_ error, not an accumulated collection of errors. +- At runtime, the result stores at most *one* error, not an accumulated collection of errors. - If both operands already contain errors, standard short-circuit evaluation applies (the left error is retained). - Normal C++ evaluation rules apply: `operator&` does not magically make I/O lazy or parallel. -When composing two `copack`s directly, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s. The variadic entry point into these rules is `fn::identity(...)`. Note that bare `scalar & scalar` is not syntactically valid by itself; you must lift them with `fn::identity(a, b)` or `fn::as_pack(a) & b`. +When composing two `copack`s directly, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s. The variadic entry point into these rules is `fn::conjoin(...)`. Note that bare `scalar & scalar` is not syntactically valid by itself; you must lift them with `fn::conjoin(a, b)` or `fn::as_pack(a) & b`. ```cpp #include @@ -432,22 +493,139 @@ auto test_cartesian_distribution() -> void { } ``` +### Conjunction with the Identity Cluster + +When performing product composition (`operator&`), you can combine fallible carriers (like `expected` or `optional`) with any member of the **identity cluster** (detailed in Section 9): + +- **Errors are unaffected**: Because identity cluster operands can never fail, they add no new types or terms to the result's error channel. The error side of the fallible operand is preserved exactly (whether plain or copack-graded). +- **Value bundling**: The value of the identity cluster operand is conjoined with the fallible operand's value channel into a `fn::pack`. +- **Unit elision**: `just` and `expected>` act as the product's identity unit and are completely elided from the value product (e.g., `expected & just` stays `expected`). +- **Choice distribution**: If a `choice` operand is conjoined with a fallible carrier, the coproduct distributes through the product. This yields a `copack` of `pack`s wrapped back inside the fallible carrier. + +```cpp +#include +#include +#include +#include +#include + +auto test_conjunction_with_identity_cluster() -> void { + fn::expected ex{42}; + fn::just j{1.5}; + + // Conjoining an expected with a just + auto res1 = ex & j; + static_assert(std::same_as< + decltype(res1), + fn::expected, Error> + >); + + // Conjoining with a unit (just) completely elides the unit + auto res2 = ex & fn::just{}; + static_assert(std::same_as); + + // Conjoining a choice causes distribution inside the carrier + fn::choice_for ch = 1.5; + auto res3 = ex & ch; + static_assert(std::same_as< + decltype(res3), + fn::expected, fn::pack>, Error> + >); +} +``` + > [!TIP] > -> ### Mathematical note — monoidal composition +> ### Mathematical note — symmetric monoidal categories and distribution > -> Product composition relies on monoidal properties: +> Simultaneously conjoining independent computations via `operator&` models a **symmetric monoidal category** $(\mathcal{C}, \otimes, I)$ where: > -> - **Associativity**: Holds after canonical `pack` flattening. -> - **Unit**: `pack<>` acts as the product unit. -> - **Distribution**: Products distribute over coproducts. -> - **Union**: For `expected`, the error grades form a union. +> - **The value tensor ($\otimes$)** corresponds to `pack` multiplication, and the unit $I$ corresponds to `pack<>`. The associator ($\alpha_{A,B,C} : (A \otimes B) \otimes C \cong A \otimes (B \otimes C)$) and unitors ($\lambda_A : I \otimes A \cong A$, $\rho_A : A \otimes I \cong A$) hold up to canonical C++ type-equivalence. +> - **Distributivity**: The tensor product $\otimes$ distributes over the coproduct $\oplus$ (represented by `copack`), yielding the canonical isomorphism: +> $$A \otimes (B \oplus C) \cong (A \otimes B) \oplus (A \otimes C)$$ +> This is precisely the Cartesian distribution of `pack` over `copack` implemented statically by `libfn`. +> - **Error Accumulation**: For `expected`, the error grades form a union, which corresponds to the monoidal composition of effects in the underlying monoid $(\mathcal{E}, \cup, \emptyset)$. > -## 6. Sequential composition with and_then +## 6. Sum composition with operator| (disjunction) + +Simultaneous sum composition combines alternative computations. By evaluating `a | b`, you attempt the left computation `a`. If it succeeds, its result is preserved. If it fails, you evaluate the right computation `b` as a fallback. + +```cpp +#include +#include +#include +#include + +auto operator_or_composition() -> void { + fn::expected a{}; + fn::expected b{}; + + auto result = a | b; + + static_assert(std::same_as< + decltype(result), + fn::expected, fn::pack> + >); +} +``` + +The runtime and compile-time semantics of disjunction are exact: + +- **Value-side sum**: + - If the successful value types of the operands differ, they are combined into a disjoint sum represented by `fn::copack`. + - If the successful value types are identical, they collapse into a single bare type `T` (e.g., `expected | expected` yields an `expected`). + - `void` results enter a genuine sum as `pack<>`. If both operands are `void`, they collapse back to `void`. +- **Error-side product**: + - Because the overall disjunction only fails if *both* operands fail, the error channel represents the product of both errors. This is recorded positionally inside `fn::pack`. + - If both operands contain graded error sets (`copack`s of errors), the errors distribute through the product: $(El + Er) \times (El') \to (El \times El') + (Er \times El')$. This yields a `copack` of `pack`s, representing all combinations of failure states. +- **Total Disjunction and the Identity Cluster**: + - If at least one operand belongs to the **identity cluster** (detailed in Section 9), the disjunction is guaranteed to never fail at runtime. + - The error side gains an uninhabited factor (`copack<>`), which collapses the error channel entirely and prevents the result from failing. + - The result is folded into a non-failing carrier of the identity cluster: a single-valued `just` if there is only one successful type, or `choice` if the sum is heterogeneous. + +The n-ary fold of `operator|` is exposed via `fn::disjoin(...)`: + +```cpp +#include +#include +#include +#include + +auto test_disjoin() -> void { + fn::expected a = 12; + fn::expected b = true; + + // Unary disjoin forwards unchanged + static_assert(std::same_as); + + // Multiple fallible and total operands compose cleanly + auto result = fn::disjoin(a, b, fn::just{1.5}); + + // Because just cannot fail, the entire disjunction becomes total + static_assert(std::same_as< + decltype(result), + fn::choice_for + >); +} +``` + +> [!TIP] +> +> ### Mathematical note — dual properties of disjunction +> +> Disjunction (`operator|`) is the categorical dual of conjunction (`operator&`): +> +> - **Value addition (Coproduct)**: Successful value channels are combined as a coproduct ($\oplus$), forming a disjoint sum. +> - **Error multiplication (Product)**: Error channels are composed as a cartesian product ($\otimes$), yielding a `pack` of errors. +> - **Product annihilation**: Admitting an identity carrier (whose error side is the initial object $0 \cong \text{copack<>}$) annihilates the error cartesian product: +> $$E \times 0 \cong 0$$ +> This mathematical property forces the error channel to collapse, rendering the entire disjunction total (never-failing) and folding the result into the identity cluster. +> +## 7. Sequential composition with and_then Sequential composition chains dependent operations where the success of one feeds the input of the next. In `libfn`, this is achieved using `and_then` (monadic bind). -A monadic carrier wraps a value. A _Kleisli arrow_ is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind. `and_then(f)` produces a storable operation value that can be piped. +A monadic carrier wraps a value. A *Kleisli arrow* is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind. `and_then(f)` produces a storable operation value that can be piped. ```cpp #include @@ -478,7 +656,7 @@ The strict "same-kind" contract defines how types interact: - All-`void` branches join cleanly to `void`, but mixed void/non-void branches are rejected. - A bare callback result belongs to `transform`, not `and_then`. -The library formalizes this "same-kind" boundary via the `fn::same_kind` concept, which lets generic templates probe whether two carrier types belong to the same monadic family: +The library formalizes this "same-kind" contract via the `fn::same_kind` concept, which lets generic templates probe whether two carrier types belong to the same monadic family: ```cpp #include @@ -493,14 +671,24 @@ static_assert(!fn::same_kind, fn::expected [!TIP] > -> ### Mathematical note — monads +> ### Mathematical note — monads as monoids in endofunctor categories +> +> A **monad** $(M, \eta, \mu)$ on a category $\mathcal{C}$ is a monoid in the monoidal category of endofunctors $([\mathcal{C}, \mathcal{C}], \circ, I_{\mathcal{C}})$. It comprises an endofunctor $M : \mathcal{C} \to \mathcal{C}$ and two natural transformations: +> +> - **Unit ($\eta : I_{\mathcal{C}} \implies M$)**: Lifts $A \to M(A)$. +> - **Multiplication ($\mu : M \circ M \implies M$)**: Flattens nested layers $M(M(A)) \to M(A)$. +> +> The binding operation ($bind : M(A) \to (A \to M(B)) \to M(B)$) is defined as: +> +> $$bind(x, f) = \mu_B(M(f)(x))$$ > -> A monad is defined by a binding operation: -> $bind : M\langle A\rangle \to (A \to M\langle B\rangle) \to M\langle B\rangle$ +> The monad laws require the following diagrams to commute (expressing associativity and unit relations): > -> A proper monad provides `pure` (or `return`) for wrapping a value, `bind` for chaining, and satisfies three monad laws: left identity, right identity, and associativity. +> $$\mu \circ M(\mu) = \mu \circ \mu_M \quad \text{and} \quad \mu \circ M(\eta) = id_M = \mu \circ \eta_M$$ > -## 7. Graded expected: exact error sets +> In C++, `and_then` implements the bind operation, while `transform` implements the endofunctor map $M(f)$. These laws are verified statically under constant evaluation in Section 13. +> +## 8. Graded expected: exact error sets `expected` grading provides exactly bounded error sets. When an outer computation holds a coproduct of successful values, and each value requires a different operation to proceed, `libfn` derives a single, normalized `expected` shape. @@ -558,16 +746,18 @@ static_assert(fn::same_kind< It is crucial to distinguish value joining from error grading: - You can join branch values while retaining a plain (non-copack) error: `expected, E>`. -- Differing _plain_ errors across branches are completely rejected. +- Differing *plain* errors across branches are completely rejected. - A `copack` on the error side is the strict opt-in to error-set unioning. To make composition more user-friendly, `libfn` allows explicit **type promotion** during sequential composition: + - In `and_then` (success binding), a plain error type `E` is automatically promoted to `copack` if the returning error type of the callback is `copack`. - In `or_else` (recovery/error binding), a plain success type `T` is automatically promoted to `copack` if the returning success type of the callback is `copack`. This ensures that you can smoothly transition from a simple, un-graded computation to a graded, multi-alternative computation when entering a pipeline step that introduces alternative paths, without needing to manually wrap or lift your starting types. If you need to perform this promotion explicitly on the carrier itself before entering a composition, `libfn` provides direct, zero-cost member helpers: + - `.copack_error()` on `expected` explicitly lifts the error, transforming `expected` to `expected>`. - `.copack_value()` on `expected` explicitly lifts the success value, transforming `expected` to `expected, E>`. - `.copack_value()` on `optional` symmetrically lifts the value, transforming `optional` to `optional>`. @@ -624,16 +814,17 @@ In practice, `expected>` acts as **the graded gateway** to start > [!TIP] > -> ### Mathematical note — the error pomonoid +> ### Mathematical note — lax monoidal unit and the neutral element +> +> Having established the error pomonoid $(\mathcal{E}, \cup, \emptyset, \subseteq)$ in Section 1, we can formally define `libfn`'s graded `expected` as a **lax monoidal functor** ($G : \mathcal{E} \to [\mathcal{C}, \mathcal{C}]$) from the pomonoid category $\mathcal{E}$ to the endofunctor category on C++ types (following Orchard, Wadler, and Eades, *Unifying graded and parameterised monads*). > -> A graded monad indexes operations over a monoid (or pomonoid). Here, the grades are finite sets of types, with set union as the binary operation, the empty set as the unit, and subset relation as the partial order. +> Under this formulation, the type `expected>` represents the **monadic unit** ($\eta$) of the graded structure: > -> Binding looks like this: -> $M_{E}\langle A\rangle \to (A \to M_{F}\langle B\rangle) \to M_{E \cup F}\langle B\rangle$ +> $$\eta_A : A \to G_I(A) \cong \text{expected}\langle A, \text{copack}\langle\rangle\rangle$$ > -> Widening an error set corresponds to effect approximation (subeffecting). For formal validation, `libfn`'s graded monad can be interpreted as a lax monoidal functor ($G : \mathcal{M} \to [\mathcal{C}, \mathcal{C}]$) as defined in Orchard, Wadler, and Eades, _Unifying graded and parameterised monads_ (Definition 21). The type `expected>` represents the **monadic unit** of this graded monad ($\eta : A \to M_{\emptyset}\langle A\rangle$), operating at the neutral identity element $\emptyset$ of the error pomonoid. Be aware that `libfn` is practical C++ software; it enforces type derivations but does not magically prove naturality or coherence laws for arbitrary user-defined closures. +> operating exactly at the neutral identity element $I = \emptyset$ of the error pomonoid. Since $\emptyset \cup F = F$, initiating a computation with this unit trigger ensures that the composition's grade accumulates subsequent effects precisely without introducing spurious terms—making it the rigorous monoidal starting gateway. > -## 8. The identity cluster +## 9. The identity cluster Certain operations behave like an identity functor across different carriers. Because some states correspond structurally, `libfn` licenses specific cross-carrier behavior to prevent redundant boilerplate. @@ -658,12 +849,12 @@ Because they are equivalent, `libfn` provides a licensed pipeline operation that auto test_identity_cross() -> void { fn::just j{UserId{}}; - // Cross-carrier pipeline bind + // Cross-carrier pipeline bind to another identity carrier auto result = j | fn::and_then([](UserId u) { - return fn::expected>{u}; + return fn::expected>{u}; }); - static_assert(std::same_as>>); + static_assert(std::same_as>>); } ``` @@ -679,17 +870,24 @@ Monadic operations behave naturally around this identity cluster: > [!TIP] > -> ### Mathematical note — the bottom grade and canonical state shapes +> ### Mathematical note — canonical state-shape isomorphisms > -> The structural equivalence of `just`, `expected>`, and `choice` over coproducts is an information-level correspondence. `libfn` realizes this correspondence through licensed binds, not through implicit C++ implicit conversions. +> The carriers in the identity cluster exhibit canonical state-shape isomorphisms in the category of types $\mathcal{C}$: > -## 9. choice: identity over a coproduct +> - `just` is the identity functor $I_{\mathcal{C}}(T) \cong T$. +> - `expected>` is the coproduct of $T$ with the initial object $0$ (the uninhabited `copack<>`), yielding the isomorphism: +> $$T + 0 \cong T$$ +> - `choice` is the single-alternative coproduct monad, isomorphic to $T$. +> +> While these objects are canonically isomorphic, C++ enforces strong nominal type boundaries. `libfn` respects this by refusing implicit conversions (which would pollute the compiler's overload resolution space), choosing instead to expose these isomorphisms through **licensed binds** (cross-carrier pipeline functors) that preserve the information-theoretic equivalence without introducing implicit conversion cycles. +> +## 10. choice: identity over a coproduct The `choice` carrier represents a computation that always succeeds by selecting one of several alternatives. Structurally, it serves as the single-layer carrier for coproduct states, avoiding the invalid nested `just>` representation discussed in Section 3. ### Decoupling via Pipeline Functors -As established in Section 8, transitions within the identity cluster are strictly restricted to pipeline-scoped functors to preserve decoupling between carriers. +As established in Section 9, transitions within the identity cluster are strictly restricted to pipeline-scoped functors to preserve decoupling between carriers. For example, a pipeline-scoped `fn::transform` on a `just` that returns a `copack` is promoted automatically to a `choice`: @@ -760,23 +958,29 @@ Bare-value callbacks are rejected by `choice`'s `and_then`. > [!TIP] > -> ### Mathematical note — map versus join +> ### Mathematical note — why copack is not a monad, but choice is +> +> Categorically, `copack` is an object-level **coproduct** (disjoint sum $\bigoplus T_i$), whereas `choice` is a **monad** representing a coproduct-bearing computation context. > -> The distinct behavior of `and_then` is formally explained by the $join$ operation: -> $join : M\langle M\langle A\rangle\rangle \to M\langle A\rangle$ -> $bind(x, f) = join(map(x, f))$ +> 1. **`copack` is self-flattening (not a monad)**: +> Naked sums are naturally self-flattening (e.g., $(A + B) + C \cong A + B + C$). This property makes nesting impossible ($M \circ M(T) \cong M(T)$), rendering the structural `join`/`flatten` operation a trivial identity map. Because mapping and binding collapse into the same operation, self-flattening structures lose the structural depth needed to satisfy the Monad identity and associativity laws. Symmetrical in its alternatives, `copack` is pure sum data, not an endofunctor. > -> For `choice`, $join$ is normalized coproduct union. The phrase "identity over a coproduct" is a statement about effect and state shape, not a formally proved C++ endofunctor across arbitrary types. +> 2. **`choice` is the monad (the "structural suspend button")**: +> To restore monad laws, the monadic carrier `choice` wraps the sum in an "identity layer" to preserve structural depth: `choice>` $\ne$ `choice`. This "structural suspend button" holds eager flattening in check. +> Thus, `choice` acts as a lawful monad under the parameterized endofunctor $M(A) = A + \bigoplus_{j} T_j$: +> - **Unit / return** ($\eta_A : A \to M(A)$): Canonical injection into the coproduct. +> - **Join / flatten** ($\mu_A : M(M(A)) \to M(A)$): Strips one layer of the `choice` wrapper, allowing the underlying sum semantics to deduplicate variants (the codiagonal fold $[id, id]$, executed statically via `choice_for`). +> - **Bind**: Composes callbacks by mapping and explicitly flattening via `join`. This explicit step grants control over *when* flattening occurs, turning a loose collection of types into a rigorous Monad. > > -## 10. Elimination and multidispatch +## 11. Elimination and multidispatch Once your computation shapes are fully derived, you must eliminate the structure to yield an ordinary C++ value. This is done via `apply` or `apply_r`. It is vital to distinguish `transform` from `apply`: -- `transform` stays _inside_ the carrier or copack, producing a new carried type. -- `apply` _eliminates_ the structure entirely, requiring all branches to converge on one deduced result type. +- `transform` stays *inside* the carrier or copack, producing a new carried type. +- `apply` *eliminates* the structure entirely, requiring all branches to converge on one deduced result type. - `apply_r` permits branch results acceptable as the specific type `R`. Application expands one selected level only. The call shapes are straightforward: @@ -816,25 +1020,32 @@ When you eliminate a carrier using `apply_type`, the active handler receives an - On `expected`, the success arm receives `std::in_place` followed by the success value, while the error arm receives `fn::unexpect` followed by the error. - On `optional`, the success arm receives `std::in_place` followed by the value, while the empty arm receives `std::nullopt`. - On `copack` and `choice`, the active alternative arm receives `std::in_place_type` followed by the payload. -- On `just` and `pack`, the active arm receives `std::in_place_type` (or `std::in_place_type` for empty/nullary states). +- On `just`, the active arm receives `std::in_place_type` (or `std::in_place_type` for empty/nullary states). > [!TIP] > -> ### Mathematical note — elimination +> ### Mathematical note — elimination of algebraic structures > -> Eliminating a coproduct means supplying a function for every single injection. Eliminating a product means supplying all its components. `apply_type` ensures the origin tag of the injection is retained during the call. +> In category theory, the dual nature of products and coproducts defines how they are **eliminated** (mapped back to ordinary objects): > -## 11. The monadic operations map +> - **Product elimination**: To eliminate a product $A \times B$, one supplies a morphism $f : A \times B \to C$ that takes all components simultaneously. In `libfn`, this is achieved by passing a multi-argument callable to a `pack`'s `apply`. +> - **Coproduct elimination**: To eliminate a coproduct $A + B$, one supplies a family of morphisms $\{f : A \to C, g : B \to C\}$ that converge on a common target. The universal property yields a unique morphism $[f, g] : A + B \to C$. In `libfn`, this maps to passing an **overload set** to a `copack`'s `apply`, where ordinary C++ overload resolution acts as the unique mediating morphism. +> +> Carrier elimination (`apply_type`) preserves the canonical injections by supplying explicit state tags (such as `std::in_place` or `std::in_place_type`) alongside the payload. This ensures that the caller retains the exact information of *which* injection morphism placed the value into the structure. +> +## 12. The monadic operations map This is a concise reference for `libfn`'s operations, organized by channel and effect: **Success Channel** + - `transform`: Maps the successful value. Stays inside the carrier. - `and_then`: Sequences computations. The mechanism for introducing new errors into a graded expected. - `filter`: Enters a short-circuit state if a predicate fails. Does not widen error grades. - `inspect`: Observes the successful value transparently. **Error/Empty Channel** + - `transform_error`: Maps the error value. Stays inside the carrier. - `or_else`: Sequences computations based on errors. Joins recovery values. - `recover`: Same as `or_else`, but always wraps raw values back into a success state. @@ -846,6 +1057,13 @@ This is a concise reference for `libfn`'s operations, organized by channel and e - `discard`: Unconditionally evaluates the carrier, discards the result, and returns `void`. This is used to signal to the compiler that the return value is deliberately ignored. +**Composition & Combination** + +- `operator&` (conjunction): Combines independent computations (values into a `pack`, errors as a union). +- `operator|` (disjunction): Combines alternative computations (values into a `copack` disjoint sum, errors as a product `pack`). +- `fn::conjoin`: An n-ary fold of `operator&` over packs, copacks, or scalars. +- `fn::disjoin`: An n-ary fold of `operator|` over monadic carriers, supporting total disjunction with the identity cluster. + ### Key Architectural Rules of the Map To reason about how these operations affect the type algebra of your computation: @@ -855,7 +1073,7 @@ To reason about how these operations affect the type algebra of your computation - **Error-side monadic operations** (like `transform_error`, `or_else`, `recover`, and `inspect_error`) are only well-formed if the carrier has an appropriate error or empty side (and are rejected on identity carriers like `just` or `choice`). -## 12. Laws as C++ equalities +## 13. Laws as C++ equalities The algebraic laws governing `libfn` shapes are verified by the compiler where structural capabilities permit. For instance, you can observe functor identity and monad left identity in `constexpr` contexts: @@ -890,13 +1108,13 @@ Other properties hold structurally: - **Identity cluster binds**: Laws hold across `just`, `choice`, and `expected>` via the canonical payload-preserving state-shape correspondence. -## 13. C++ mechanics that preserve the algebra +## 14. C++ mechanics that preserve the algebra To make the algebraic model reliable in everyday C++, `libfn` uses extensive compiler mechanisms to reject malformed usage and preserve performance properties. ### Constraints and exhaustiveness -Public concepts and `requires` clauses enforce correctness before instantiation. Operations are protected by applicability concepts (negative probes) that proactively reject impossible calls. This underpins the compile-time exhaustiveness guarantees of `apply` and monadic operations established in Sections 3 and 10, catching unhandled alternatives at the boundary of instantiation rather than deep inside template machinery. +Public concepts and `requires` clauses enforce correctness before instantiation. Operations are protected by applicability concepts (negative probes) that proactively reject impossible calls. This underpins the compile-time exhaustiveness guarantees of `apply` and monadic operations established in Sections 3 and 11, catching unhandled alternatives at the boundary of instantiation rather than deep inside template machinery. ### C++ value properties From d84ae8fdc2533e35b16b9be2e4decad4e522caf3 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 12:34:25 +0100 Subject: [PATCH 03/51] docs: Smooth TYPE_ALGEBRA.md style and fold trivial examples * Fold fn::as_pack directly into existing test_pack() example * Fold fn::as_copack and ADL get() into existing test_copack() example * Clean up and streamline prose for primary narrative * Ensure category-theoretic mathematical notes are consistent and rigorous Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 48 +++++++++--------------------------------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 9ffa4c87..816c2d1b 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -259,7 +259,7 @@ When a callable supplied to `and_then` needs to produce an error grade, it can e ### pack: all fields are present -A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: attempting to nest a `pack` inside another `pack` via `append` flattens them, as a flat pack is canonical. +A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: attempting to nest a `pack` inside another `pack` via `append` flattens them, as a flat pack is canonical. To explicitly lift a single scalar value into a `pack` (which is useful when conjoining a scalar with another pack or copack), use `fn::as_pack(value)`. ```cpp #include @@ -285,20 +285,10 @@ auto test_pack() -> void { decltype(wider), fn::pack >); -} -``` - -#### Singular lift with fn::as_pack -To explicitly lift a single scalar value into a `pack`, use `fn::as_pack(value)`. This is particularly useful when conjoining a scalar with another pack or copack: - -```cpp -#include -#include - -auto test_as_pack(int x = 42) -> void { - auto p = fn::as_pack(x); - static_assert(std::same_as>); + // Explicitly lifting a single scalar value into a pack: + auto lifted = fn::as_pack(42); + static_assert(std::same_as>); } ``` @@ -306,7 +296,7 @@ auto test_as_pack(int x = 42) -> void { A `copack` stores exactly one of its defined alternatives. Consider a system processing different kinds of lexer tokens or configuration values. -When you evaluate a `copack` via its member `apply` function, it expands one selected outer level only. The active alternative currently stored inside the coproduct is passed as a terminal argument to your callback, rather than recursively unpacking any internal structures. +When you evaluate a `copack` via its member `apply` function, it expands one selected outer level only. The active alternative currently stored inside the coproduct is passed as a terminal argument to your callback, rather than recursively unpacking any internal structures. To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. ```cpp #include @@ -326,35 +316,15 @@ auto test_copack() -> void { }); static_assert(value == 1); -} -``` - -A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. -#### Singular lift with fn::as_copack - -To explicitly lift a single scalar value into a `copack` (creating a single-alternative coproduct), use `fn::as_copack(value)`. This is the primary mechanism to manually lift a raw type into a graded state: - -```cpp -#include -#include - -auto test_as_copack(int x = 42) -> void { - auto cp = fn::as_copack(x); - static_assert(std::same_as>); -} -``` - -When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL): - -```cpp -auto extract_singular(fn::copack cp) -> int { + // Singular lift and direct value extraction (only allowed for singular copacks): + auto cp = fn::as_copack(42); using std::get; - return get(cp); // Has the exact same reference-propagating semantics as fn::apply / .apply + static_assert(std::same_as); } ``` -This SFINAE-clean accessor is strictly constrained and disallowed for multi-alternative `copack` types, guaranteeing that compile-time exhaustiveness cannot be bypassed. +A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. This SFINAE-clean behavior is why direct `get` extraction is strictly constrained and disallowed for multi-alternative `copack` types, ensuring that compile-time exhaustiveness cannot be bypassed. ### The computation carriers From 00804a4f8646992c5425b74d9739e162d0a0a212 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 12:45:54 +0100 Subject: [PATCH 04/51] docs: Unify note style for programmer-facing notes in TYPE_ALGEBRA.md * Re-format copack vs copack_for under-the-hood section as standard [!NOTE] block * Re-format nested coproduct just/choice section as standard [!NOTE] block Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 816c2d1b..2ff9e608 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -197,7 +197,7 @@ auto test_copack_set_semantics() -> void { > [!NOTE] > -> ### Under the Hood: copack vs. copack_for +> ### Note — copack vs. copack_for > > In C++, there is no native language feature to represent a "set of types." Template parameter lists are always positional, variadic sequences. Syntactically, this means `copack` and `copack` would be completely distinct types—a property that directly violates the mathematical commutative law of set union. > @@ -339,9 +339,14 @@ Because `choice` implies that an alternative is always present, `choice<>` is in **Rule:** A carrier does not need another carrier for multidispatch. Inside an `expected` or `optional`, store your alternative states as `copack`. Use `choice` only when those alternatives are themselves the outer, never-failing computation. -A programmer might be tempted to represent a never-failing, multi-alternative computation by nesting a coproduct inside an identity carrier, spelling it `just>`. In `libfn`'s type algebra, this is precisely the space filled by `choice`. Structurally, `choice` is equivalent to a `just` container of a `copack`, providing a single-layer monadic carrier that represents a never-failing computation over a coproduct. - -In fact, attempting to instantiate `just>` will trigger a compile-time static assertion failure inside `just`, explicitly warning the programmer: `"a just over a copack is spelled choice"`. +> [!NOTE] +> +> ### Note — `just>` is spelled `choice` +> +> +> A programmer might be tempted to represent a never-failing, multi-alternative computation by nesting a coproduct inside an identity carrier, spelling it `just>`. In `libfn`'s type algebra, this is precisely the space filled by `choice`. Structurally, `choice` is equivalent to a `just` container of a `copack`, providing a single-layer monadic carrier that represents a never-failing computation over a coproduct. +> +> In fact, attempting to instantiate `just>` will trigger a compile-time static assertion failure inside `just`, explicitly warning the programmer: `"a just over a copack is spelled choice"`. These carriers impose constraints on their payloads, but reference payloads are broadly supported where sound. For example, `optional` is supported and well-defined. From 3073750f7d2294a2650a41658aa9b22b67f61606 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 12:48:56 +0100 Subject: [PATCH 05/51] docs: Fill the monadic operations gap in identity cluster section * Detail how success mapping (transform) promotions work with pipeline operators * Detail how recovery (or_else/recover) and fallbacks (value_or) are compile-rejected or vacuously dead * Connect identity cluster behaviors systematically to the Section 12 operations map Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 2ff9e608..ee489ec7 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -839,9 +839,12 @@ Furthermore, fallible types like `expected` (with inhabited error states) and `o Monadic operations behave naturally around this identity cluster: -- `inspect` and `discard` remain meaningful. -- Dead-side operations like `inspect_error` or `transform_error` reject `just` and `choice`, and act vacuously on `expected>`. -- `fail` and `filter` reject the identity cluster entirely, because no failure state can possibly be constructed from an identity carrier. +- **Success mapping (`transform`)**: Remains meaningful and stays inside the nominal carrier family when using member functions. However, when using the pipeline `operator|`, returning a `copack` from `fn::transform` on an identity carrier automatically promotes the result to `choice` (as detailed in Section 10). +- **Sequential binding (`and_then`)**: Allows cross-carrier transitions *within* the identity cluster (e.g., `just` to `expected>`) when using pipeline-scoped `fn::and_then`. +- **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable. +- **Short-circuiting (`fail`, `filter`)**: Strictly rejected for all identity cluster carriers, because no failure state (an inhabited error or empty state) can possibly be constructed from a never-failing identity context. +- **Elimination fallbacks (`value_or`)**: Strictly rejected on `just` and `choice` since they can never fail, rendering any fallback redundant and dead. On `expected>`, `value_or` is vacuously well-formed, but the fallback branch is optimized away as unreachable. +- **Neutral observation (`inspect`, `discard`)**: Fully supported and behave normally. > [!TIP] > From 832826f92fd3d85764e682bf6ca1f0d2d5c1536b Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 12:52:03 +0100 Subject: [PATCH 06/51] docs: Re-organize Section 12 to capture the fail/recover dual symmetry * Move fail to the Success Channel group as a success-intercepting transition operation * Move recover to the Error/Empty Channel group as a failure-intercepting transition operation * Add explicit dual symmetry rules for fail and recover under Key Architectural Rules Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index ee489ec7..fc432ed9 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -841,9 +841,9 @@ Monadic operations behave naturally around this identity cluster: - **Success mapping (`transform`)**: Remains meaningful and stays inside the nominal carrier family when using member functions. However, when using the pipeline `operator|`, returning a `copack` from `fn::transform` on an identity carrier automatically promotes the result to `choice` (as detailed in Section 10). - **Sequential binding (`and_then`)**: Allows cross-carrier transitions *within* the identity cluster (e.g., `just` to `expected>`) when using pipeline-scoped `fn::and_then`. -- **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable. +- **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable (to allow generic code on `expected` to compile) - **Short-circuiting (`fail`, `filter`)**: Strictly rejected for all identity cluster carriers, because no failure state (an inhabited error or empty state) can possibly be constructed from a never-failing identity context. -- **Elimination fallbacks (`value_or`)**: Strictly rejected on `just` and `choice` since they can never fail, rendering any fallback redundant and dead. On `expected>`, `value_or` is vacuously well-formed, but the fallback branch is optimized away as unreachable. +- **Elimination fallbacks (`value_or`)**: Strictly rejected on `just` and `choice` since they can never fail, rendering any fallback redundant and dead. On `expected>`, `value_or` is vacuously well-formed, but the fallback branch is optimized away as unreachable (to allow generic code on `expected` to compile without errors) - **Neutral observation (`inspect`, `discard`)**: Fully supported and behave normally. > [!TIP] @@ -1018,18 +1018,18 @@ This is a concise reference for `libfn`'s operations, organized by channel and e **Success Channel** - `transform`: Maps the successful value. Stays inside the carrier. -- `and_then`: Sequences computations. The mechanism for introducing new errors into a graded expected. +- `and_then`: Sequences success-path computations. The mechanism for introducing new errors into a graded expected. - `filter`: Enters a short-circuit state if a predicate fails. Does not widen error grades. - `inspect`: Observes the successful value transparently. +- `fail`: Intercepts success and forces a transition to a failure state. Does not widen error grades. **Error/Empty Channel** - `transform_error`: Maps the error value. Stays inside the carrier. - `or_else`: Sequences computations based on errors. Joins recovery values. -- `recover`: Same as `or_else`, but always wraps raw values back into a success state. +- `recover`: Intercepts failure and forces a transition back to a success state. - `inspect_error`: Observes the error value transparently. - `value_or`: Eliminates the carrier by supplying a fallback value on failure. -- `fail`: Short-circuits success into a forced failure state. Does not widen error grades. **Neutral** @@ -1046,6 +1046,7 @@ This is a concise reference for `libfn`'s operations, organized by channel and e To reason about how these operations affect the type algebra of your computation: +- **`fail` and `recover` are dual symmetries**: `fail` intercepts a success-path value and forces a transition to the failure state ($Success \implies Failure$). `recover` intercepts a failure-path error and forces a transition back to the success state ($Failure \implies Success$). Neither operation widens the error set of a graded carrier. - **Graded `and_then`** is the primary mechanism for introducing a _new_ error type (widening the error grade) into your pipeline. - **`filter` and `fail`** merely enter an _existing_ short-circuit state. They do not widen the error grade (the type must already be capable of holding the failure state). - **Error-side monadic operations** (like `transform_error`, `or_else`, `recover`, and `inspect_error`) are only well-formed if the carrier has an appropriate error or empty side (and are rejected on identity carriers like `just` or `choice`). From 9b0edd818d93531983d4ac0a8971359895b667a2 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 12:53:40 +0100 Subject: [PATCH 07/51] docs: Update TYPE_ALGEBRA.md introduction to cover disjunction and n-ary folds * Add operator| (disjunction / simultaneous sum composition) to the core operations list * Clarify that operator& represents conjunction * Explicitly introduce the fn::conjoin and fn::disjoin n-ary fold utilities Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index fc432ed9..04fd2153 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -11,7 +11,7 @@ The library operates on a few core vocabulary types: - `choice`: Never-failing computation holding one of several alternatives. - `just`: Identity computation always yielding a single value. -Composition operations include `transform` (mapping), `and_then` (sequential monadic binding), `operator&` (simultaneous product composition), and `apply` (multidispatch elimination). +Composition operations include `transform` (mapping), `and_then` (sequential monadic binding), `operator&` (conjunction / simultaneous product composition), `operator|` (disjunction / simultaneous sum composition), the n-ary folds `fn::conjoin` and `fn::disjoin`, and `apply` (multidispatch elimination). ### Member vs. Pipeline Syntax From 4ad0ac656f39a03e1471399912c598a1083ba3c3 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 13:24:44 +0100 Subject: [PATCH 08/51] docs: Refine copack multidispatch accuracy and carrier terminology in TYPE_ALGEBRA.md * Correct copack apply description to note recursive unpacking of nested tuple-likes * Document the terminal data guarantee of normalized sum-of-products multidispatch * Refine carrier terminology to gently introduce 'monadic' context as functional jargon * Polished layout into two clean, readable paragraphs in Section 3 Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 04fd2153..3be5b49d 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -124,7 +124,7 @@ auto product_composition() -> void { } ``` -The result contains a `pack` of the successful values and a `copack` of the exact possible errors. +The result contains a `pack` (a flat, tuple-like product) of the successful values, and a `copack` (a sorted, variant-like disjoint sum) of the exact possible errors. ### The Two Cooperating Mechanisms @@ -206,7 +206,7 @@ auto test_copack_set_semantics() -> void { > - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in strict lexicographical order (the order defined by C++26 `std::type_order`, which `libfn` emulates for pre-C++26 compilers). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` lexicographically precedes `B`) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** > - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and lexicographical sorting automatically, and resolving directly to the validated canonical `copack` type. > -> To the C++ programmer, they can be used interchangeably because `copack_for` always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. +> To the C++ programmer, they can be used interchangeably because `copack_for` (as a type alias) always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. The laws governing `copack` are: @@ -215,7 +215,7 @@ The laws governing `copack` are: - **Idempotent**: Duplicate types are collapsed into one. - **Identity**: `copack<>` acts as the union unit (adding `copack<>` changes nothing). -Because canonical ordering collapses identical types, distinct types must never be silently lost if the ordering cannot distinguish them. Consequently, types combined into a `copack` should be strongly typed tag structs or distinct domain objects, not generic primitives whose semantic meaning depends on their position (e.g., `copack` becomes `copack`). +Because canonical ordering collapses identical types, distinct types must never be silently lost if the ordering cannot distinguish them. Consequently, types combined into a `copack` should be strongly typed tag structs or distinct domain objects, not generic primitives whose semantic meaning depends on their position (e.g., `copack_for` collapses to `copack`, discarding the positional distinction). ### The algebra is strictly opt-in @@ -294,9 +294,11 @@ auto test_pack() -> void { ### copack: one exact alternative is present -A `copack` stores exactly one of its defined alternatives. Consider a system processing different kinds of lexer tokens or configuration values. +A `copack` represents a canonical disjoint sum (or coproduct) storing exactly one of its defined alternative types. This is ideal for modeling variant-like structures, such as lexical tokens or parsed configuration keys. -When you evaluate a `copack` via its member `apply` function, it expands one selected outer level only. The active alternative currently stored inside the coproduct is passed as a terminal argument to your callback, rather than recursively unpacking any internal structures. To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. +When you evaluate a `copack` via its member `apply` function, it selects the active alternative stored inside the coproduct and passes it to your callback. Because `copack` is self-flattening, you are guaranteed that there is never a nested `copack` inside. However, any nested tuple-like structures—such as `pack`, `std::tuple`, or `std::array`—are recursively unpacked into their individual constituents during multidispatch. By keeping your shapes normalized as a sum-of-products, `libfn` guarantees that your callbacks always receive clean, terminal domain data directly as function arguments. + +To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. ```cpp #include @@ -328,7 +330,7 @@ A fundamental safety guarantee of `copack` is **exhaustive matching**. Any opera ### The computation carriers -To model computation, `libfn` uses carrier types: +To model computation, `libfn` uses carrier types (often referred to as "monadic" contexts in functional programming): - `just`: Always contains a single successful value. - `optional`: Contains a value or is empty. From fcb76950e389d41ae8fc21dbe3cdd2c6cb19a865 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 23 Jul 2026 18:17:19 +0100 Subject: [PATCH 09/51] docs: Fix constrained template parameters grammar and build examples * Fix spelling and grammatical structure of the constrained parameters warning * Fully integrate examples/type_algebra/main.cpp into the project CMake build * Enable pre-commit enforcement of the synchronized code regions * Verify compile-clean success across all 44 unit and example tests Assisted-by: Gemini:gemini-3.6-flash --- .pre-commit-config.yaml | 6 + TYPE_ALGEBRA.md | 628 +++++++++++--------------- examples/CMakeLists.txt | 1 + examples/type_algebra/CMakeLists.txt | 51 +++ examples/type_algebra/main.cpp | 423 +++++++++++++++++ scripts/sync_type_algebra_examples.py | 78 ++++ 6 files changed, 814 insertions(+), 373 deletions(-) create mode 100644 examples/type_algebra/CMakeLists.txt create mode 100644 examples/type_algebra/main.cpp create mode 100755 scripts/sync_type_algebra_examples.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98d92c38..7cc0f2b1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -67,3 +67,9 @@ repos: language: python files: ^(README\.md|examples/readme/main\.cpp)$ pass_filenames: false + - id: sync-type-algebra-examples + name: sync TYPE_ALGEBRA examples with examples/type_algebra + entry: python scripts/sync_type_algebra_examples.py + language: python + files: ^(TYPE_ALGEBRA\.md|examples/type_algebra/main\.cpp)$ + pass_filenames: false diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 3be5b49d..713fe03d 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -29,6 +29,7 @@ In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected` Although different types can behave identically during application, they remain strictly distinct in memory. For example, `pack`, `std::tuple`, and `std::pair` all unpack into the same call shape `f(a, b)` during `apply`, but they are separate C++ types with distinct layouts. Application does not silently convert or unify types on the storage side. To illustrate these concepts, the examples in this document use a reusable set of value and error types: + ```cpp struct UserId {}; struct User {}; @@ -38,7 +39,9 @@ struct BlockSize {}; struct NotANumber {}; struct OutOfRange {}; -struct Missing {}; +struct Missing { + auto operator<=>(Missing const &) const = default; +}; struct IoError {}; struct BadSyntax {}; struct UnknownKey {}; @@ -50,27 +53,19 @@ In idiomatic C++, error handling usually means picking one application-wide erro With `libfn`, the compiler derives an exact, graded error pipeline. Consider parsing, validating, and loading a user: + ```cpp -#include -#include -#include -#include -#include - auto parse_id(std::string_view) -> fn::expected>; auto validate(UserId) -> fn::expected>; auto load(UserId) -> fn::expected>; -auto graded_pipeline(std::string_view sv) -> void { - auto pipeline = parse_id(sv) - | fn::and_then(validate) - | fn::and_then(load); +auto graded_pipeline(std::string_view sv) -> void +{ + auto pipeline = parse_id(sv) | fn::and_then(validate) | fn::and_then(load); - // The exact derived error union is recorded in the type: - static_assert(std::same_as< - decltype(pipeline), - fn::expected> - >); + // The exact derived error union is recorded in the type: + static_assert( + std::same_as>>); } ``` @@ -105,22 +100,16 @@ The resulting error type is **graded**: it dynamically expands (or narrows durin Simultaneous product composition achieves the same precision for both values and errors. Using `operator&`, you can evaluate independent computations and bundle their results: + ```cpp -#include -#include -#include -#include - -auto product_composition() -> void { - fn::expected> id{}; - fn::expected> user{}; +auto product_composition() -> void +{ + fn::expected> id{}; + fn::expected> user{}; - auto bundled = id & user; + auto bundled = id & user; - static_assert(std::same_as< - decltype(bundled), - fn::expected, fn::copack> - >); + static_assert(std::same_as, fn::copack>>); } ``` @@ -177,21 +166,17 @@ Consider the difference in these carrier states: A major feature of `libfn` is that `copack` forms canonical sets of types, in contrast to the positional indexing of `std::variant`. When you combine types into a coproduct, `fn::copack_for` guarantees deduplication, flattening, and a canonical ordering. + ```cpp -#include -#include - -auto test_copack_set_semantics() -> void { - using SetA = fn::copack; - using SetB = fn::copack; +auto test_copack_set_semantics() -> void +{ + using SetA = fn::copack; + using SetB = fn::copack; - // Flattening, deduplication, and reordering happen automatically: - using Union = fn::copack_for>; + // Flattening, deduplication, and reordering happen automatically: + using Union = fn::copack_for>; - static_assert(std::same_as< - Union, - fn::copack - >); + static_assert(std::same_as>); } ``` @@ -261,34 +246,33 @@ When a callable supplied to `and_then` needs to produce an error grade, it can e A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: attempting to nest a `pack` inside another `pack` via `append` flattens them, as a flat pack is canonical. To explicitly lift a single scalar value into a `pack` (which is useful when conjoining a scalar with another pack or copack), use `fn::as_pack(value)`. + ```cpp -#include -#include +auto test_pack() -> void +{ + fn::pack p{UserId{}, User{}}; // CTAD -auto test_pack() -> void { - fn::pack p{UserId{}, User{}}; // CTAD + auto [id, user] = p; // Structured bindings work naturally + (void)id; + (void)user; - auto [id, user] = p; // Structured bindings work naturally + // Ordered, non-deduplicated fields + using P = fn::pack; + (void)sizeof(P); - // Ordered, non-deduplicated fields - using P = fn::pack; + // Found via ADL (like std::get) + using std::get; + static_assert(std::same_as(p)), UserId &>); - // Found via ADL (like std::get) - using std::get; - static_assert(std::same_as(p)), UserId&>); + // Splicing scalars or other packs via append: + auto row = fn::pack{UserId{}}.append(FilePath{}); + auto wider = std::move(row).append(fn::pack{true, 3}); - // Splicing scalars or other packs via append: - auto row = fn::pack{UserId{}}.append(FilePath{}); - auto wider = std::move(row).append(fn::pack{true, 3}); + static_assert(std::same_as>); - static_assert(std::same_as< - decltype(wider), - fn::pack - >); - - // Explicitly lifting a single scalar value into a pack: - auto lifted = fn::as_pack(42); - static_assert(std::same_as>); + // Explicitly lifting a single scalar value into a pack: + auto lifted = fn::as_pack(42); + static_assert(std::same_as>); } ``` @@ -300,29 +284,24 @@ When you evaluate a `copack` via its member `apply` function, it selects the act To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. + ```cpp -#include -#include -#include - struct IntegerToken {}; struct StringToken {}; -auto test_copack() -> void { - constexpr fn::copack token = IntegerToken{}; +auto test_copack() -> void +{ + constexpr fn::copack token = IntegerToken{}; - // Member apply eliminates the copack by routing the active alternative to an overload set: - constexpr auto value = token.apply(fn::overload{ - [](IntegerToken) { return 1; }, - [](StringToken) { return 2; } - }); + // Member apply eliminates the copack by routing the active alternative to an overload set: + constexpr auto value = token.apply(fn::overload{[](IntegerToken) { return 1; }, [](StringToken) { return 2; }}); - static_assert(value == 1); + static_assert(value == 1); - // Singular lift and direct value extraction (only allowed for singular copacks): - auto cp = fn::as_copack(42); - using std::get; - static_assert(std::same_as); + // Singular lift and direct value extraction (only allowed for singular copacks): + auto cp = fn::as_copack(42); + using std::get; + static_assert(std::same_as); } ``` @@ -356,32 +335,19 @@ These carriers impose constraints on their payloads, but reference payloads are Mapping allows you to change the contained data without altering the structural success/failure shape of the computation. `libfn` uses `transform` (functor map) to operate on the successful channel, and `transform_error` for the error channel. + ```cpp -#include -#include -#include -#include -#include -#include - -auto mapping_values_and_errors() -> void { - fn::expected> ex{}; - - auto mapped_val = ex | fn::transform([](UserId) { return User{}; }); - static_assert(std::same_as< - decltype(mapped_val), - fn::expected> - >); - - auto mapped_err = ex | fn::transform_error(fn::overload{ - [](Missing) { return BadSyntax{}; }, - [](IoError e) { return e; } - }); - - static_assert(std::same_as< - decltype(mapped_err), - fn::expected> - >); +auto mapping_values_and_errors() -> void +{ + fn::expected> ex{}; + + auto mapped_val = ex | fn::transform([](UserId) { return User{}; }); + static_assert(std::same_as>>); + + auto mapped_err + = ex | fn::transform_error(fn::overload{[](Missing) { return BadSyntax{}; }, [](IoError e) { return e; }}); + + static_assert(std::same_as>>); } ``` @@ -411,22 +377,16 @@ Key principles of mapping: Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results. + ```cpp -#include -#include -#include -#include +auto operator_and_composition() -> void +{ + fn::expected> a{}; + fn::expected> b{}; -auto operator_and_composition() -> void { - fn::expected> a{}; - fn::expected> b{}; + auto result = a & b; - auto result = a & b; - - static_assert(std::same_as< - decltype(result), - fn::expected, fn::copack> - >); + static_assert(std::same_as, fn::copack>>); } ``` @@ -439,34 +399,22 @@ The runtime failure semantics are exact: When composing two `copack`s directly, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s. The variadic entry point into these rules is `fn::conjoin(...)`. Note that bare `scalar & scalar` is not syntactically valid by itself; you must lift them with `fn::conjoin(a, b)` or `fn::as_pack(a) & b`. + ```cpp -#include -#include -#include - -struct A {}; -struct B {}; -struct C {}; -struct D {}; - -auto test_cartesian_distribution() -> void { - constexpr fn::copack ab = A{}; - constexpr fn::copack cd = C{}; - - // The product distributes over the coproduct: (A + B) x (C + D) -> AC + AD + BC + BD - auto result1 = ab & cd; - static_assert(std::same_as< - decltype(result1), - fn::copack_for, fn::pack, fn::pack, fn::pack> - >); - - // The distributive law also works on a pack: (A × B) × (C + D) = ABC + ABD - constexpr fn::pack Pab = {A{}, B{}}; - auto result2 = Pab & cd; - static_assert(std::same_as< - decltype(result2), - fn::copack_for, fn::pack> - >); +auto test_cartesian_distribution() -> void +{ + constexpr fn::copack ab = A{}; + constexpr fn::copack cd = C{}; + + auto result1 = ab & cd; + + static_assert( + std::same_as, fn::pack, fn::pack, fn::pack>>); + + constexpr fn::pack Pab = {A{}, B{}}; + auto result2 = Pab & cd; + + static_assert(std::same_as, fn::pack>>); } ``` @@ -479,35 +427,26 @@ When performing product composition (`operator&`), you can combine fallible carr - **Unit elision**: `just` and `expected>` act as the product's identity unit and are completely elided from the value product (e.g., `expected & just` stays `expected`). - **Choice distribution**: If a `choice` operand is conjoined with a fallible carrier, the coproduct distributes through the product. This yields a `copack` of `pack`s wrapped back inside the fallible carrier. + ```cpp -#include -#include -#include -#include -#include - -auto test_conjunction_with_identity_cluster() -> void { - fn::expected ex{42}; - fn::just j{1.5}; - - // Conjoining an expected with a just - auto res1 = ex & j; - static_assert(std::same_as< - decltype(res1), - fn::expected, Error> - >); - - // Conjoining with a unit (just) completely elides the unit - auto res2 = ex & fn::just{}; - static_assert(std::same_as); - - // Conjoining a choice causes distribution inside the carrier - fn::choice_for ch = 1.5; - auto res3 = ex & ch; - static_assert(std::same_as< - decltype(res3), - fn::expected, fn::pack>, Error> - >); +auto test_conjunction_with_identity_cluster() -> void +{ + fn::expected ex{42}; + fn::just j{1.5}; + + // Conjoining an expected with a just + auto res1 = ex & j; + static_assert(std::same_as, Error>>); + + // Conjoining with a unit (just) completely elides the unit + auto res2 = ex & fn::just{}; + static_assert(std::same_as); + + // Conjoining a choice causes distribution inside the carrier + fn::choice_for ch = 1.5; + auto res3 = ex & ch; + static_assert( + std::same_as, fn::pack>, Error>>); } ``` @@ -527,22 +466,16 @@ auto test_conjunction_with_identity_cluster() -> void { Simultaneous sum composition combines alternative computations. By evaluating `a | b`, you attempt the left computation `a`. If it succeeds, its result is preserved. If it fails, you evaluate the right computation `b` as a fallback. + ```cpp -#include -#include -#include -#include +auto operator_or_composition() -> void +{ + fn::expected a{}; + fn::expected b{}; -auto operator_or_composition() -> void { - fn::expected a{}; - fn::expected b{}; + auto result = a | b; - auto result = a | b; - - static_assert(std::same_as< - decltype(result), - fn::expected, fn::pack> - >); + static_assert(std::same_as, fn::pack>>); } ``` @@ -562,27 +495,21 @@ The runtime and compile-time semantics of disjunction are exact: The n-ary fold of `operator|` is exposed via `fn::disjoin(...)`: + ```cpp -#include -#include -#include -#include - -auto test_disjoin() -> void { - fn::expected a = 12; - fn::expected b = true; - - // Unary disjoin forwards unchanged - static_assert(std::same_as); - - // Multiple fallible and total operands compose cleanly - auto result = fn::disjoin(a, b, fn::just{1.5}); - - // Because just cannot fail, the entire disjunction becomes total - static_assert(std::same_as< - decltype(result), - fn::choice_for - >); +auto test_disjoin() -> void +{ + fn::expected a = 12; + fn::expected b = true; + + // Unary disjoin forwards unchanged (maintaining rvalue value-categories) + static_assert(std::same_as); + + // Multiple fallible and total operands compose cleanly + auto result = fn::disjoin(a, b, fn::just{1.5}); + + // Because just cannot fail, the entire disjunction becomes total + static_assert(std::same_as>); } ``` @@ -604,22 +531,16 @@ Sequential composition chains dependent operations where the success of one feed A monadic carrier wraps a value. A *Kleisli arrow* is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind. `and_then(f)` produces a storable operation value that can be piped. + ```cpp -#include -#include -#include -#include - -auto parse() -> fn::expected>; -auto load(UserId) -> fn::expected>; +auto parse_numeric() -> fn::expected>; +auto load_user(UserId) -> fn::expected>; -auto sequential_bind() -> void { - auto result = parse() | fn::and_then(load); +auto sequential_bind() -> void +{ + auto result = parse_numeric() | fn::and_then(load_user); - static_assert(std::same_as< - decltype(result), - fn::expected> - >); + static_assert(std::same_as>>); } ``` @@ -635,12 +556,8 @@ The strict "same-kind" contract defines how types interact: The library formalizes this "same-kind" contract via the `fn::same_kind` concept, which lets generic templates probe whether two carrier types belong to the same monadic family: + ```cpp -#include -#include -#include -#include - static_assert(fn::same_kind, fn::optional>); static_assert(fn::same_kind, fn::expected>); static_assert(!fn::same_kind, fn::expected>); @@ -671,33 +588,23 @@ static_assert(!fn::same_kind, fn::expected ```cpp -#include -#include -#include -#include -#include - -auto read_config() -> fn::expected< - fn::copack_for, - fn::copack_for ->; - -auto config_pipeline() -> void { - auto validated = read_config() | fn::and_then(fn::overload{ - [](MaximumSize v) { return fn::expected>{v}; }, - [](FilePath v) { return fn::expected>{v}; }, - [](BlockSize v) { return fn::expected>{v}; } - }); - - // The result exactly bounds both the successful paths and the error paths - static_assert(std::same_as< - decltype(validated), - fn::expected< - fn::copack, - fn::copack - > - >); +auto read_config() + -> fn::expected, fn::copack_for>; + +auto config_pipeline() -> void +{ + auto validated + = read_config() + | fn::and_then(fn::overload{[](MaximumSize v) { return fn::expected>{v}; }, + [](FilePath v) { return fn::expected>{v}; }, + [](BlockSize v) { return fn::expected>{v}; }}); + + // The result exactly bounds both the successful paths and the error paths + static_assert( + std::same_as, + fn::copack>>); } ``` @@ -708,16 +615,9 @@ Two independent joins occurred during `and_then`: This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` requires the exact same error type `E` to participate in monadic bind (meaning `expected` and `expected` are **not** `same_kind`), any two graded `expected` types are considered `same_kind`, regardless of how their individual error sets differ: + ```cpp -#include -#include -#include -#include - -static_assert(fn::same_kind< - fn::expected>, - fn::expected> ->); +static_assert(fn::same_kind>, fn::expected>>); ``` It is crucial to distinguish value joining from error grading: @@ -741,33 +641,21 @@ If you need to perform this promotion explicitly on the carrier itself before en These helper methods provide a compact, explicit alternative to implicit pipeline promotions: + ```cpp -#include -#include -#include -#include - -auto test_explicit_lifting(fn::expected result, fn::optional opt) -> void { - // Explicitly lift the error side of expected: - auto graded_err = std::move(result).copack_error(); - static_assert(std::same_as< - decltype(graded_err), - fn::expected> - >); - - // Explicitly lift the value side of expected: - auto graded_val = std::move(result).copack_value(); - static_assert(std::same_as< - decltype(graded_val), - fn::expected, IoError> - >); - - // Explicitly lift the value side of optional: - auto graded_opt = std::move(opt).copack_value(); - static_assert(std::same_as< - decltype(graded_opt), - fn::optional> - >); +auto test_explicit_lifting(fn::expected result, fn::optional opt) -> void +{ + // Explicitly lift the error side of expected: + auto graded_err = std::move(result).copack_error(); + static_assert(std::same_as>>); + + // Explicitly lift the value side of expected: + auto graded_val = std::move(result).copack_value(); + static_assert(std::same_as, IoError>>); + + // Explicitly lift the value side of optional: + auto graded_opt = std::move(opt).copack_value(); + static_assert(std::same_as>>); } ``` @@ -780,7 +668,8 @@ In accordance with the subeffecting principles of graded monads (Section 1), a n The bottom error grade is `copack<>`: ```cpp -fn::expected> cannot_fail{}; +template +using cannot_fail_t = fn::expected>; ``` This computation cannot fail, but it is algebraically prepared to widen if later composition introduces possible errors. @@ -817,21 +706,16 @@ These three computation carriers have canonically isomorphic state shapes—they Because they are equivalent, `libfn` provides a licensed pipeline operation that allows binding across these boundaries: + ```cpp -#include -#include -#include -#include - -auto test_identity_cross() -> void { - fn::just j{UserId{}}; +auto test_identity_cross() -> void +{ + fn::just j{UserId{}}; - // Cross-carrier pipeline bind to another identity carrier - auto result = j | fn::and_then([](UserId u) { - return fn::expected>{u}; - }); + // Cross-carrier pipeline bind to another identity carrier + auto result = j | fn::and_then([](UserId u) { return fn::expected>{u}; }); - static_assert(std::same_as>>); + static_assert(std::same_as>>); } ``` @@ -871,22 +755,16 @@ As established in Section 9, transitions within the identity cluster are strictl For example, a pipeline-scoped `fn::transform` on a `just` that returns a `copack` is promoted automatically to a `choice`: + ```cpp -#include -#include -#include -#include -#include +auto test_identity_transformation() -> void +{ + fn::just j{UserId{}}; -auto test_identity_transformation() -> void { - fn::just j{UserId{}}; + // Transforming a just with a callable returning a copack produces a choice + auto mapped = j | fn::transform([](UserId) { return fn::copack_for{Missing{}}; }); - // Transforming a just with a callable returning a copack produces a choice - auto mapped = j | fn::transform([](UserId) { - return fn::copack_for{Missing{}}; - }); - - static_assert(std::same_as>); + static_assert(std::same_as>); } ``` @@ -899,38 +777,24 @@ Inside its own carrier domain, `choice` behaves differently from a bare `copack` Consider a scenario where different branches of a switch return different `choice` types: + ```cpp -#include -#include -#include -#include -#include -#include - -auto test_choice_mapping() -> void { - fn::choice_for ch{UserId{}}; - - // transform nests the returned choice as a mapped value - auto mapped = ch | fn::transform(fn::overload{ - [](UserId) { return fn::choice{Missing{}}; }, - [](User) { return fn::choice{FilePath{}}; } - }); - - static_assert(std::same_as< - decltype(mapped), - fn::choice_for, fn::choice> - >); - - // and_then joins and flattens them into a normalized superset choice - auto bound = ch | fn::and_then(fn::overload{ - [](UserId) { return fn::choice{Missing{}}; }, - [](User) { return fn::choice{FilePath{}}; } - }); - - static_assert(std::same_as< - decltype(bound), - fn::choice - >); +auto test_choice_mapping() -> void +{ + fn::choice_for ch{UserId{}}; + + constexpr auto mapper = fn::overload{[](UserId) { return fn::choice{Missing{}}; }, + [](User) { return fn::choice{FilePath{}}; }}; + + // transform nests the returned choice as a mapped value + auto mapped = ch | fn::transform(mapper); + + static_assert(std::same_as, fn::choice>>); + + // and_then joins and flattens them into a normalized superset choice + auto bound = ch | fn::and_then(mapper); + + static_assert(std::same_as>); } ``` @@ -963,6 +827,16 @@ It is vital to distinguish `transform` from `apply`: - `apply` *eliminates* the structure entirely, requiring all branches to converge on one deduced result type. - `apply_r` permits branch results acceptable as the specific type `R`. +> [!NOTE] +> +> ### Note — Type Convergence vs. Collapsing +> +> To preserve C++ type-safety, there is a fundamental difference in how return types are handled: +> +> - **Elimination (`apply`)** strictly requires every branch of your overload set to return the **exact same type** (or be convertible to `R` in `apply_r`). Since `apply` exits the library's algebraic domain to return a raw C++ value, a single convergent return type must be statically deduced. +> - *Tip:* You can bypass this strict identical-type constraint by using `apply_r>`. Because any alternative is implicitly convertible to its parent `copack` (via canonical injection constructors), different branches are permitted to return completely heterogeneous types (like `A`, `B`, or `C` respectively) and unify cleanly back into that single copack target! +> - **Mapping (`transform`)** on `copack` relaxes this restriction by leveraging `libfn`'s **collapsing machinery**. If different branches return different copacks or scalars, `transform` automatically gathers, flattens, and deduplicates those heterogeneous types into a single, unified `copack_for` result—safely keeping the computation within the algebraic domain. + Application expands one selected level only. The call shapes are straightforward: | Type | Eliminated Call Shape | @@ -976,21 +850,22 @@ Application expands one selected level only. The call shapes are straightforward A whole-carrier `expected` application cleanly handles both success and error paths into one result type: + ```cpp -#include -#include -#include // for fn::overload - -auto test_elimination(fn::expected> ex) -> int { - return ex.apply(fn::overload{ - [](UserId) { return 1; }, - [](Missing) { return 0; } - }); +auto test_elimination(fn::expected> ex) -> int +{ + return ex.apply(fn::overload{[](UserId) { return 1; }, [](Missing) { return 0; }}); } ``` Exhaustiveness is statically constrained. If you omit a handler for a possible type, the compilation fails. `fn::overload` is merely a helper; final selection always relies on ordinary C++ overload resolution. +> [!WARNING] +> +> ### Warning — Greedy Catch-Alls Defeat Exhaustiveness +> +> Avoid using unconstrained generic template parameters (such as `[](auto &&)` or `[](auto)`) in your overload sets unless you explicitly intend to discard type distinction. Because C++ overload resolution selects these as greedy catch-alls for any unhandled types, they will silently satisfy the compiler and completely defeat `libfn`'s compile-time exhaustiveness guarantees, hiding missing or unhandled branch errors. You may find that constrained template parameters (such as `[](MyConcept auto&&)`) are a useful middle ground. + ### Type-tagged elimination Because multiple structures can share the same unpacking call shape (e.g., `pack` and `std::tuple` both call `f(a, b)`), untagged `apply` can sometimes erase the structural context of the state. To preserve this context and prevent permissive C++ implicit conversions from accidentally conflating different states, `libfn` provides the **`apply_type`** (and `apply_type_r`) member functions. @@ -1058,24 +933,20 @@ To reason about how these operations affect the type algebra of your computation The algebraic laws governing `libfn` shapes are verified by the compiler where structural capabilities permit. For instance, you can observe functor identity and monad left identity in `constexpr` contexts: + ```cpp -#include -#include -#include -#include -#include - -constexpr auto test_laws() -> void { - fn::expected> ex{42}; - - // Functor Identity: mapping with identity yields the same value - auto id = [](auto v) { return v; }; - static_assert((ex | fn::transform(id)) == ex); - - // Monad Left Identity: pure(x) >>= f is equivalent to f(x) - auto pure = [](int v) { return fn::expected>{v}; }; - auto f = [](int v) { return fn::expected>{v * 2}; }; - static_assert((pure(42) | fn::and_then(f)) == f(42)); +constexpr auto test_laws() -> void +{ + constexpr fn::expected> ex{42}; + + // Functor Identity: mapping with identity yields the same value + auto id = [](auto v) { return v; }; + static_assert((ex | fn::transform(id)) == ex); + + // Monad Left Identity: pure(x) >>= f is equivalent to f(x) + auto pure = [](int v) { return fn::expected>{v}; }; + auto f = [](int v) { return fn::expected>{v * 2}; }; + static_assert((pure(42) | fn::and_then(f)) == f(42)); } ``` @@ -1105,16 +976,27 @@ Public concepts and `requires` clauses enforce correctness before instantiation. - `noexcept` is conditionally computed based on the operations provided. - Value categories (lvalue/rvalue) propagate strictly to callbacks, avoiding unnecessary copies. - Immovable and move-only payloads are fully supported in place. -- Reference-bearing packs and `optional` are deliberately supported. Lifetime responsibility for non-owning references remains with the caller. +- Reference-bearing `pack` and `optional` are fully supported. Lifetime responsibility for non-owning references remains with the caller. +> [!NOTE] +> +> ### Note — Reference Restrictions on Carriers +> +> To preserve the C++ standard's structural constraints, raw reference payloads are strictly disallowed as primary template parameters on carriers like `expected`, `copack`, `choice`, or `just`. If you want to propagate references inside these carriers, you must wrap them inside a `pack` (e.g. `expected, E>`). + + ```cpp -#include -#include +auto test_references() -> void +{ + int x = 42; + + // optional supports references directly + fn::optional opt{x}; + static_assert(std::same_as); -auto test_references() -> void { - int x = 42; - fn::optional opt{x}; - static_assert(std::same_as); + // expected must wrap references inside a pack + fn::expected, Error> ex{fn::as_pack(x)}; + static_assert(std::same_as &>); } ``` diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index d3b5b793..6409d43e 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -4,3 +4,4 @@ add_subdirectory(calculator) add_subdirectory(polygon) add_subdirectory(readme) add_subdirectory(simple) +add_subdirectory(type_algebra) diff --git a/examples/type_algebra/CMakeLists.txt b/examples/type_algebra/CMakeLists.txt new file mode 100644 index 00000000..8d7ef1de --- /dev/null +++ b/examples/type_algebra/CMakeLists.txt @@ -0,0 +1,51 @@ +cmake_minimum_required(VERSION 3.25) +project(examples_type_algebra) + +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Pls keep the filenames sorted +set(EXAMPLES_TYPE_ALGEBRA_SOURCES + main.cpp +) + +foreach(mode 20 23 26) + if (NOT VALIDATE_CXX23 AND mode EQUAL 23) + continue() + endif() + + if (NOT VALIDATE_CXX26 AND mode EQUAL 26) + continue() + endif() + + # Current releases of MSVC only support C++20 + if(MSVC AND NOT (mode EQUAL 20)) + continue() + endif() + + if(mode EQUAL 26) + set(entry_point include_fn_cxx26) + else() + set(entry_point include_fn) + endif() + + set(target "examples_type_algebra_cxx${mode}") + + add_executable("${target}" ${EXAMPLES_TYPE_ALGEBRA_SOURCES}) + target_link_libraries("${target}" "${entry_point}") + append_compilation_options("${target}" WARNINGS) + add_dependencies("cxx${mode}" "${target}") + add_dependencies("examples" "${target}") + add_dependencies("tests" "${target}") + set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${mode}") + target_compile_definitions("${target}" PRIVATE LIBFN_MODE=${mode}) + + add_test( + NAME "${target}" + COMMAND "${target}" + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) + set_property(TEST "${target}" PROPERTY LABELS examples "cxx${mode}") + + unset(target) +endforeach() diff --git a/examples/type_algebra/main.cpp b/examples/type_algebra/main.cpp new file mode 100644 index 00000000..b3bbd95e --- /dev/null +++ b/examples/type_algebra/main.cpp @@ -0,0 +1,423 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct Error {}; +struct OtherError {}; + +struct A {}; +struct B {}; +struct C {}; +struct D {}; + +// sync-example-types-def +struct UserId {}; +struct User {}; +struct FilePath {}; +struct MaximumSize {}; +struct BlockSize {}; + +struct NotANumber {}; +struct OutOfRange {}; +struct Missing { + auto operator<=>(Missing const &) const = default; +}; +struct IoError {}; +struct BadSyntax {}; +struct UnknownKey {}; +// sync-example-types-def + +// Dummy definitions for non-linked prototypes to prevent linker errors: +fn::expected> parse_id(std::string_view) +{ + return fn::expected>{::fn::unexpect, NotANumber{}}; +} +fn::expected> validate(UserId) +{ + return fn::expected>{::fn::unexpect, OutOfRange{}}; +} +fn::expected> load(UserId) +{ + return fn::expected>{::fn::unexpect, IoError{}}; +} +fn::expected> parse_numeric() +{ + return fn::expected>{::fn::unexpect, NotANumber{}}; +} +fn::expected> load_user(UserId) +{ + return fn::expected>{::fn::unexpect, Missing{}}; +} +fn::expected, fn::copack_for> read_config() +{ + return fn::expected, fn::copack_for>{ + ::fn::unexpect, BadSyntax{}}; +} + +// sync-example-graded-pipeline +auto parse_id(std::string_view) -> fn::expected>; +auto validate(UserId) -> fn::expected>; +auto load(UserId) -> fn::expected>; + +auto graded_pipeline(std::string_view sv) -> void +{ + auto pipeline = parse_id(sv) | fn::and_then(validate) | fn::and_then(load); + + // The exact derived error union is recorded in the type: + static_assert( + std::same_as>>); +} +// sync-example-graded-pipeline + +// sync-example-product-composition +auto product_composition() -> void +{ + fn::expected> id{}; + fn::expected> user{}; + + auto bundled = id & user; + + static_assert(std::same_as, fn::copack>>); +} +// sync-example-product-composition + +// sync-example-copack-set-semantics +auto test_copack_set_semantics() -> void +{ + using SetA = fn::copack; + using SetB = fn::copack; + + // Flattening, deduplication, and reordering happen automatically: + using Union = fn::copack_for>; + + static_assert(std::same_as>); +} +// sync-example-copack-set-semantics + +// sync-example-test-pack +auto test_pack() -> void +{ + fn::pack p{UserId{}, User{}}; // CTAD + + auto [id, user] = p; // Structured bindings work naturally + (void)id; + (void)user; + + // Ordered, non-deduplicated fields + using P = fn::pack; + (void)sizeof(P); + + // Found via ADL (like std::get) + using std::get; + static_assert(std::same_as(p)), UserId &>); + + // Splicing scalars or other packs via append: + auto row = fn::pack{UserId{}}.append(FilePath{}); + auto wider = std::move(row).append(fn::pack{true, 3}); + + static_assert(std::same_as>); + + // Explicitly lifting a single scalar value into a pack: + auto lifted = fn::as_pack(42); + static_assert(std::same_as>); +} +// sync-example-test-pack + +// sync-example-test-copack +struct IntegerToken {}; +struct StringToken {}; + +auto test_copack() -> void +{ + constexpr fn::copack token = IntegerToken{}; + + // Member apply eliminates the copack by routing the active alternative to an overload set: + constexpr auto value = token.apply(fn::overload{[](IntegerToken) { return 1; }, [](StringToken) { return 2; }}); + + static_assert(value == 1); + + // Singular lift and direct value extraction (only allowed for singular copacks): + auto cp = fn::as_copack(42); + using std::get; + static_assert(std::same_as); +} +// sync-example-test-copack + +// sync-example-mapping-values-and-errors +auto mapping_values_and_errors() -> void +{ + fn::expected> ex{}; + + auto mapped_val = ex | fn::transform([](UserId) { return User{}; }); + static_assert(std::same_as>>); + + auto mapped_err + = ex | fn::transform_error(fn::overload{[](Missing) { return BadSyntax{}; }, [](IoError e) { return e; }}); + + static_assert(std::same_as>>); +} +// sync-example-mapping-values-and-errors + +// sync-example-operator-and-composition +auto operator_and_composition() -> void +{ + fn::expected> a{}; + fn::expected> b{}; + + auto result = a & b; + + static_assert(std::same_as, fn::copack>>); +} +// sync-example-operator-and-composition + +// sync-example-cartesian-distribution +auto test_cartesian_distribution() -> void +{ + constexpr fn::copack ab = A{}; + constexpr fn::copack cd = C{}; + + auto result1 = ab & cd; + + static_assert( + std::same_as, fn::pack, fn::pack, fn::pack>>); + + constexpr fn::pack Pab = {A{}, B{}}; + auto result2 = Pab & cd; + + static_assert(std::same_as, fn::pack>>); +} +// sync-example-cartesian-distribution + +// sync-example-conjunction-with-identity-cluster +auto test_conjunction_with_identity_cluster() -> void +{ + fn::expected ex{42}; + fn::just j{1.5}; + + // Conjoining an expected with a just + auto res1 = ex & j; + static_assert(std::same_as, Error>>); + + // Conjoining with a unit (just) completely elides the unit + auto res2 = ex & fn::just{}; + static_assert(std::same_as); + + // Conjoining a choice causes distribution inside the carrier + fn::choice_for ch = 1.5; + auto res3 = ex & ch; + static_assert( + std::same_as, fn::pack>, Error>>); +} +// sync-example-conjunction-with-identity-cluster + +// sync-example-operator-or-composition +auto operator_or_composition() -> void +{ + fn::expected a{}; + fn::expected b{}; + + auto result = a | b; + + static_assert(std::same_as, fn::pack>>); +} +// sync-example-operator-or-composition + +// sync-example-test-disjoin +auto test_disjoin() -> void +{ + fn::expected a = 12; + fn::expected b = true; + + // Unary disjoin forwards unchanged (maintaining rvalue value-categories) + static_assert(std::same_as); + + // Multiple fallible and total operands compose cleanly + auto result = fn::disjoin(a, b, fn::just{1.5}); + + // Because just cannot fail, the entire disjunction becomes total + static_assert(std::same_as>); +} +// sync-example-test-disjoin + +// sync-example-sequential-bind +auto parse_numeric() -> fn::expected>; +auto load_user(UserId) -> fn::expected>; + +auto sequential_bind() -> void +{ + auto result = parse_numeric() | fn::and_then(load_user); + + static_assert(std::same_as>>); +} +// sync-example-sequential-bind + +// sync-example-test-same-kind +static_assert(fn::same_kind, fn::optional>); +static_assert(fn::same_kind, fn::expected>); +static_assert(!fn::same_kind, fn::expected>); +// sync-example-test-same-kind + +// sync-example-config-pipeline +auto read_config() + -> fn::expected, fn::copack_for>; + +auto config_pipeline() -> void +{ + auto validated + = read_config() + | fn::and_then(fn::overload{[](MaximumSize v) { return fn::expected>{v}; }, + [](FilePath v) { return fn::expected>{v}; }, + [](BlockSize v) { return fn::expected>{v}; }}); + + // The result exactly bounds both the successful paths and the error paths + static_assert( + std::same_as, + fn::copack>>); +} +// sync-example-config-pipeline + +// sync-example-test-same-kind-graded +static_assert(fn::same_kind>, fn::expected>>); +// sync-example-test-same-kind-graded + +// sync-example-test-explicit-lifting +auto test_explicit_lifting(fn::expected result, fn::optional opt) -> void +{ + // Explicitly lift the error side of expected: + auto graded_err = std::move(result).copack_error(); + static_assert(std::same_as>>); + + // Explicitly lift the value side of expected: + auto graded_val = std::move(result).copack_value(); + static_assert(std::same_as, IoError>>); + + // Explicitly lift the value side of optional: + auto graded_opt = std::move(opt).copack_value(); + static_assert(std::same_as>>); +} +// sync-example-test-explicit-lifting + +template using cannot_fail_t = fn::expected>; + +// sync-example-test-identity-cross +auto test_identity_cross() -> void +{ + fn::just j{UserId{}}; + + // Cross-carrier pipeline bind to another identity carrier + auto result = j | fn::and_then([](UserId u) { return fn::expected>{u}; }); + + static_assert(std::same_as>>); +} +// sync-example-test-identity-cross + +// sync-example-test-identity-transformation +auto test_identity_transformation() -> void +{ + fn::just j{UserId{}}; + + // Transforming a just with a callable returning a copack produces a choice + auto mapped = j | fn::transform([](UserId) { return fn::copack_for{Missing{}}; }); + + static_assert(std::same_as>); +} +// sync-example-test-identity-transformation + +// sync-example-test-choice-mapping +auto test_choice_mapping() -> void +{ + fn::choice_for ch{UserId{}}; + + constexpr auto mapper = fn::overload{[](UserId) { return fn::choice{Missing{}}; }, + [](User) { return fn::choice{FilePath{}}; }}; + + // transform nests the returned choice as a mapped value + auto mapped = ch | fn::transform(mapper); + + static_assert(std::same_as, fn::choice>>); + + // and_then joins and flattens them into a normalized superset choice + auto bound = ch | fn::and_then(mapper); + + static_assert(std::same_as>); +} +// sync-example-test-choice-mapping + +// sync-example-test-elimination +auto test_elimination(fn::expected> ex) -> int +{ + return ex.apply(fn::overload{[](UserId) { return 1; }, [](Missing) { return 0; }}); +} +// sync-example-test-elimination + +// sync-example-test-laws +constexpr auto test_laws() -> void +{ + constexpr fn::expected> ex{42}; + + // Functor Identity: mapping with identity yields the same value + auto id = [](auto v) { return v; }; + static_assert((ex | fn::transform(id)) == ex); + + // Monad Left Identity: pure(x) >>= f is equivalent to f(x) + auto pure = [](int v) { return fn::expected>{v}; }; + auto f = [](int v) { return fn::expected>{v * 2}; }; + static_assert((pure(42) | fn::and_then(f)) == f(42)); +} +// sync-example-test-laws + +// sync-example-test-references +auto test_references() -> void +{ + int x = 42; + + // optional supports references directly + fn::optional opt{x}; + static_assert(std::same_as); + + // expected must wrap references inside a pack + fn::expected, Error> ex{fn::as_pack(x)}; + static_assert(std::same_as &>); +} +// sync-example-test-references + +int main() +{ + // Touch all functions to prove compilability and execution + std::string_view sv = ""; + graded_pipeline(sv); + product_composition(); + test_copack_set_semantics(); + test_pack(); + test_copack(); + mapping_values_and_errors(); + operator_and_composition(); + test_cartesian_distribution(); + test_conjunction_with_identity_cluster(); + operator_or_composition(); + test_disjoin(); + config_pipeline(); + test_identity_cross(); + test_identity_transformation(); + test_choice_mapping(); + test_laws(); + test_references(); + return 0; +} diff --git a/scripts/sync_type_algebra_examples.py b/scripts/sync_type_algebra_examples.py new file mode 100755 index 00000000..c8921e51 --- /dev/null +++ b/scripts/sync_type_algebra_examples.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Keep TYPE_ALGEBRA.md code examples in sync with examples/type_algebra/main.cpp. + +examples/type_algebra/main.cpp is the single source of truth: CI builds and runs it, +proving that all examples are compilable. The regions bounded by `// sync-example-` +are mirrored into the matching `` code fences in TYPE_ALGEBRA.md. +""" +import pathlib +import re +import sys + +repo = pathlib.Path(__file__).resolve().parents[1] +example_path = repo / "examples" / "type_algebra" / "main.cpp" +doc_path = repo / "TYPE_ALGEBRA.md" + +if not example_path.exists(): + sys.stderr.write(f"Error: {example_path} does not exist\n") + sys.exit(1) + +if not doc_path.exists(): + sys.stderr.write(f"Error: {doc_path} does not exist\n") + sys.exit(1) + +# 1. Parse all regions from examples/type_algebra/main.cpp +example_text = example_path.read_text(encoding="utf-8") +example_lines = example_text.splitlines() + +regions = {} +current_name = None +current_block = [] + +for line in example_lines: + stripped = line.strip() + if stripped.startswith("// sync-example-"): + name = stripped[len("// sync-example-"):] + if current_name is None: + # Start of a region + current_name = name + current_block = [] + elif current_name == name: + # End of a region + regions[current_name] = "\n".join(current_block) + current_name = None + else: + sys.stderr.write(f"Error: unmatched boundary in main.cpp: started {current_name}, got {name}\n") + sys.exit(1) + elif current_name is not None: + current_block.append(line) + +print(f"Extracted {len(regions)} verified code regions from {example_path.name}") + +# 2. Read and synchronize TYPE_ALGEBRA.md +doc_text = doc_path.read_text(encoding="utf-8") + +# We find followed by ```cpp ``` +# and replace the content with the region. +def replace_snippet(match): + name = match.group(1) + if name not in regions: + sys.stderr.write(f"Warning: no matching region in main.cpp for sync-example-{name}\n") + return match.group(0) # Keep unchanged + + return f"\n```cpp\n{regions[name]}\n```" + +# Match: followed by whitespace and ```cpp ... ``` +pattern = re.compile(r"\s*```cpp\n.*?```", re.DOTALL) +updated_text = pattern.sub(replace_snippet, doc_text) + +# Clean up trailing whitespace in the document (like pre-commit trailing whitespace check does) +updated_text = re.sub(r"[ \t]+$", "", updated_text, flags=re.MULTILINE) + +if doc_text != updated_text: + doc_path.write_text(updated_text, encoding="utf-8") + print(f"Synced code fences in {doc_path.relative_to(repo)} <- {example_path.relative_to(repo)}") + sys.exit(1) + +print("All TYPE_ALGEBRA.md code examples are fully synchronized!") +sys.exit(0) From 9cb0f969fc6427d520ef1c303ce9f31954fd71f7 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 24 Jul 2026 13:36:19 +0100 Subject: [PATCH 10/51] docs: Relax type-ordering in examples and refine pack/copack explanations * Fix C++26 compilation errors with LIBFN_CXX26 * Polish copack, copack_for, choice, and choice_for terminology for strict ordering * Document and illustrate value-category preservation in deduction-only as_pack * Document and verify by-value decay and multi-argument coercion in explicit as_pack * Synchronize all code regions in TYPE_ALGEBRA.md with examples/type_algebra/main.cpp Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 84 +++++++++++++++++++++------------- examples/type_algebra/main.cpp | 71 +++++++++++++++++----------- include/fn/pack.hpp | 15 +++++- 3 files changed, 110 insertions(+), 60 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 713fe03d..0d5ad3fd 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -57,7 +57,7 @@ With `libfn`, the compiler derives an exact, graded error pipeline. Consider par ```cpp auto parse_id(std::string_view) -> fn::expected>; auto validate(UserId) -> fn::expected>; -auto load(UserId) -> fn::expected>; +auto load(UserId) -> fn::expected>; auto graded_pipeline(std::string_view sv) -> void { @@ -65,7 +65,7 @@ auto graded_pipeline(std::string_view sv) -> void // The exact derived error union is recorded in the type: static_assert( - std::same_as>>); + std::same_as>>); } ``` @@ -109,7 +109,8 @@ auto product_composition() -> void auto bundled = id & user; - static_assert(std::same_as, fn::copack>>); + static_assert( + std::same_as, fn::copack_for>>); } ``` @@ -170,13 +171,13 @@ A major feature of `libfn` is that `copack` forms canonical sets of types, in co ```cpp auto test_copack_set_semantics() -> void { - using SetA = fn::copack; - using SetB = fn::copack; + using SetA = fn::copack_for; + using SetB = fn::copack_for; // Flattening, deduplication, and reordering happen automatically: using Union = fn::copack_for>; - static_assert(std::same_as>); + static_assert(std::same_as>); } ``` @@ -191,7 +192,9 @@ auto test_copack_set_semantics() -> void > - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in strict lexicographical order (the order defined by C++26 `std::type_order`, which `libfn` emulates for pre-C++26 compilers). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` lexicographically precedes `B`) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** > - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and lexicographical sorting automatically, and resolving directly to the validated canonical `copack` type. > -> To the C++ programmer, they can be used interchangeably because `copack_for` (as a type alias) always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. +> To the C++ programmer, they can often be treated as equivalent in APIs because `copack_for` (as a type alias) always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. When writing types out manually, `copack` requires the types to already be in strict canonical order, whereas `copack_for` handles arbitrary, out-of-order, or duplicated lists. +> +> Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. Just like `copack`, `choice` requires its template parameters to be in strict canonical order, whereas `choice_for` accepts arbitrary, out-of-order, or duplicated lists. The laws governing `copack` are: @@ -244,7 +247,11 @@ When a callable supplied to `and_then` needs to produce an error grade, it can e ### pack: all fields are present -A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: attempting to nest a `pack` inside another `pack` via `append` flattens them, as a flat pack is canonical. To explicitly lift a single scalar value into a `pack` (which is useful when conjoining a scalar with another pack or copack), use `fn::as_pack(value)`. +A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: attempting to nest a `pack` inside another `pack` via `append` flattens them, as a flat pack is canonical. + +To explicitly lift values into a `pack` (which is useful when conjoining scalars with other packs or copacks), use `fn::as_pack(...)`. When called without template parameters, `as_pack` is deduction-only and preserves the value category of its arguments: `as_pack(42)` yields `pack`, whereas calling `as_pack(x)` on an lvalue `x` yields `pack` (a reference rather than a copy). + +Symmetrically, explicitly specifying the template parameters (e.g., `as_pack(x, d)`) opts out of reference preservation. In this explicit form, arguments are passed by-value (meaning lvalues are copied or decayed), enabling implicit type conversions and coercions at the call boundary. Note that partial template spelling is not supported; all element types must be spelled out explicitly if template parameters are specified. ```cpp @@ -271,8 +278,21 @@ auto test_pack() -> void static_assert(std::same_as>); // Explicitly lifting a single scalar value into a pack: - auto lifted = fn::as_pack(42); - static_assert(std::same_as>); + int x = 42; + auto lifted_lvalue = fn::as_pack(x); + static_assert(std::same_as>); + + auto lifted_rvalue = fn::as_pack(42); + static_assert(std::same_as>); + + // Spelling the element type explicitly opts out of reference preservation - an owned copy: + auto copied = fn::as_pack(x); + static_assert(std::same_as>); + + // The explicit form also coerces - the argument converts at the call boundary: + double d = 3.14; + auto coerced = fn::as_pack(x, d); + static_assert(std::same_as>); } ``` @@ -291,7 +311,7 @@ struct StringToken {}; auto test_copack() -> void { - constexpr fn::copack token = IntegerToken{}; + constexpr fn::copack_for token = IntegerToken{}; // Member apply eliminates the copack by routing the active alternative to an overload set: constexpr auto value = token.apply(fn::overload{[](IntegerToken) { return 1; }, [](StringToken) { return 2; }}); @@ -305,7 +325,7 @@ auto test_copack() -> void } ``` -A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. This SFINAE-clean behavior is why direct `get` extraction is strictly constrained and disallowed for multi-alternative `copack` types, ensuring that compile-time exhaustiveness cannot be bypassed. +A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. The same discipline explains why direct `get` extraction is disallowed for multi-alternative `copack` types: allowing partial extraction would bypass compile-time exhaustiveness guarantees. ### The computation carriers @@ -342,12 +362,12 @@ auto mapping_values_and_errors() -> void fn::expected> ex{}; auto mapped_val = ex | fn::transform([](UserId) { return User{}; }); - static_assert(std::same_as>>); + static_assert(std::same_as>>); auto mapped_err = ex | fn::transform_error(fn::overload{[](Missing) { return BadSyntax{}; }, [](IoError e) { return e; }}); - static_assert(std::same_as>>); + static_assert(std::same_as>>); } ``` @@ -386,7 +406,7 @@ auto operator_and_composition() -> void auto result = a & b; - static_assert(std::same_as, fn::copack>>); + static_assert(std::same_as, fn::copack_for>>); } ``` @@ -394,17 +414,17 @@ The runtime failure semantics are exact: - The result type statically records all possible errors. - At runtime, the result stores at most *one* error, not an accumulated collection of errors. -- If both operands already contain errors, standard short-circuit evaluation applies (the left error is retained). +- If both operands already contain errors, the left error is retained. Because C++ operators evaluate eagerly, both operands are already fully constructed before `operator&` runs — this is an error-selection rule, not runtime short-circuiting. - Normal C++ evaluation rules apply: `operator&` does not magically make I/O lazy or parallel. -When composing two `copack`s directly, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s. The variadic entry point into these rules is `fn::conjoin(...)`. Note that bare `scalar & scalar` is not syntactically valid by itself; you must lift them with `fn::conjoin(a, b)` or `fn::as_pack(a) & b`. +When composing two `copack`s directly, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s. The variadic entry point into these rules is `fn::conjoin(...)`. Note that bare `scalar & scalar` never enters the algebra by itself: for class types it fails to compile, and for built-in types like `int` it resolves to the built-in bitwise AND. To conjoin scalars, lift them with `fn::conjoin(a, b)` or `fn::as_pack(a) & b` instead. ```cpp auto test_cartesian_distribution() -> void { - constexpr fn::copack ab = A{}; - constexpr fn::copack cd = C{}; + constexpr fn::copack_for ab = A{}; + constexpr fn::copack_for cd = C{}; auto result1 = ab & cd; @@ -443,7 +463,7 @@ auto test_conjunction_with_identity_cluster() -> void static_assert(std::same_as); // Conjoining a choice causes distribution inside the carrier - fn::choice_for ch = 1.5; + fn::choice ch = 1.5; auto res3 = ex & ch; static_assert( std::same_as, fn::pack>, Error>>); @@ -509,7 +529,7 @@ auto test_disjoin() -> void auto result = fn::disjoin(a, b, fn::just{1.5}); // Because just cannot fail, the entire disjunction becomes total - static_assert(std::same_as>); + static_assert(std::same_as>); } ``` @@ -540,7 +560,7 @@ auto sequential_bind() -> void { auto result = parse_numeric() | fn::and_then(load_user); - static_assert(std::same_as>>); + static_assert(std::same_as>>); } ``` @@ -603,8 +623,8 @@ auto config_pipeline() -> void // The result exactly bounds both the successful paths and the error paths static_assert( - std::same_as, - fn::copack>>); + std::same_as, + fn::copack_for>>); } ``` @@ -764,7 +784,7 @@ auto test_identity_transformation() -> void // Transforming a just with a callable returning a copack produces a choice auto mapped = j | fn::transform([](UserId) { return fn::copack_for{Missing{}}; }); - static_assert(std::same_as>); + static_assert(std::same_as>); } ``` @@ -781,7 +801,7 @@ Consider a scenario where different branches of a switch return different `choic ```cpp auto test_choice_mapping() -> void { - fn::choice_for ch{UserId{}}; + fn::choice ch{UserId{}}; constexpr auto mapper = fn::overload{[](UserId) { return fn::choice{Missing{}}; }, [](User) { return fn::choice{FilePath{}}; }}; @@ -794,7 +814,7 @@ auto test_choice_mapping() -> void // and_then joins and flattens them into a normalized superset choice auto bound = ch | fn::and_then(mapper); - static_assert(std::same_as>); + static_assert(std::same_as>); } ``` @@ -875,7 +895,7 @@ When you eliminate a carrier using `apply_type`, the active handler receives an - On `expected`, the success arm receives `std::in_place` followed by the success value, while the error arm receives `fn::unexpect` followed by the error. - On `optional`, the success arm receives `std::in_place` followed by the value, while the empty arm receives `std::nullopt`. - On `copack` and `choice`, the active alternative arm receives `std::in_place_type` followed by the payload. -- On `just`, the active arm receives `std::in_place_type` (or `std::in_place_type` for empty/nullary states). +- On `just`, the arm receives `std::in_place_type` followed by the value. Symmetrically, `just`'s arm receives `std::in_place_type` alone — representing a nullary unit payload (never an empty or uninitialized state). > [!TIP] > @@ -940,12 +960,12 @@ constexpr auto test_laws() -> void constexpr fn::expected> ex{42}; // Functor Identity: mapping with identity yields the same value - auto id = [](auto v) { return v; }; + constexpr auto id = [](auto v) { return v; }; static_assert((ex | fn::transform(id)) == ex); // Monad Left Identity: pure(x) >>= f is equivalent to f(x) - auto pure = [](int v) { return fn::expected>{v}; }; - auto f = [](int v) { return fn::expected>{v * 2}; }; + constexpr auto pure = [](int v) { return fn::expected>{v}; }; + constexpr auto f = [](int v) { return fn::expected>{v * 2}; }; static_assert((pure(42) | fn::and_then(f)) == f(42)); } ``` @@ -1005,7 +1025,7 @@ auto test_references() -> void The library is divided into layers: - `pfn` (Polyfill fn) is the standards-facing layer. It provides polyfills of `std::optional` and `std::expected`, conforming to standard C++26 (and later) shapes. -- `fn` is the strict extension layer. It introduces the `pack`/`copack` algebra, multidispatch, graded errors, `choice`, `just`, and the cross-carrier pipeline monadic operation (`operator|`). +- `fn` is the strict extension layer. It introduces the `pack`/`copack` algebra, multidispatch, graded errors, `choice`, `just`, the pipeline verbs, and the composition operators `&` and `|`. ## Functional terminology diff --git a/examples/type_algebra/main.cpp b/examples/type_algebra/main.cpp index b3bbd95e..31a15ffa 100644 --- a/examples/type_algebra/main.cpp +++ b/examples/type_algebra/main.cpp @@ -53,9 +53,9 @@ fn::expected> validate(UserId) { return fn::expected>{::fn::unexpect, OutOfRange{}}; } -fn::expected> load(UserId) +fn::expected> load(UserId) { - return fn::expected>{::fn::unexpect, IoError{}}; + return fn::expected>{::fn::unexpect, IoError{}}; } fn::expected> parse_numeric() { @@ -74,7 +74,7 @@ fn::expected, fn::copack_for fn::expected>; auto validate(UserId) -> fn::expected>; -auto load(UserId) -> fn::expected>; +auto load(UserId) -> fn::expected>; auto graded_pipeline(std::string_view sv) -> void { @@ -82,7 +82,7 @@ auto graded_pipeline(std::string_view sv) -> void // The exact derived error union is recorded in the type: static_assert( - std::same_as>>); + std::same_as>>); } // sync-example-graded-pipeline @@ -94,20 +94,21 @@ auto product_composition() -> void auto bundled = id & user; - static_assert(std::same_as, fn::copack>>); + static_assert( + std::same_as, fn::copack_for>>); } // sync-example-product-composition // sync-example-copack-set-semantics auto test_copack_set_semantics() -> void { - using SetA = fn::copack; - using SetB = fn::copack; + using SetA = fn::copack_for; + using SetB = fn::copack_for; // Flattening, deduplication, and reordering happen automatically: using Union = fn::copack_for>; - static_assert(std::same_as>); + static_assert(std::same_as>); } // sync-example-copack-set-semantics @@ -135,8 +136,21 @@ auto test_pack() -> void static_assert(std::same_as>); // Explicitly lifting a single scalar value into a pack: - auto lifted = fn::as_pack(42); - static_assert(std::same_as>); + int x = 42; + auto lifted_lvalue = fn::as_pack(x); + static_assert(std::same_as>); + + auto lifted_rvalue = fn::as_pack(42); + static_assert(std::same_as>); + + // Spelling the element type explicitly opts out of reference preservation - an owned copy: + auto copied = fn::as_pack(x); + static_assert(std::same_as>); + + // The explicit form also coerces - the argument converts at the call boundary: + double d = 3.14; + auto coerced = fn::as_pack(x, d); + static_assert(std::same_as>); } // sync-example-test-pack @@ -146,7 +160,7 @@ struct StringToken {}; auto test_copack() -> void { - constexpr fn::copack token = IntegerToken{}; + constexpr fn::copack_for token = IntegerToken{}; // Member apply eliminates the copack by routing the active alternative to an overload set: constexpr auto value = token.apply(fn::overload{[](IntegerToken) { return 1; }, [](StringToken) { return 2; }}); @@ -166,12 +180,12 @@ auto mapping_values_and_errors() -> void fn::expected> ex{}; auto mapped_val = ex | fn::transform([](UserId) { return User{}; }); - static_assert(std::same_as>>); + static_assert(std::same_as>>); auto mapped_err = ex | fn::transform_error(fn::overload{[](Missing) { return BadSyntax{}; }, [](IoError e) { return e; }}); - static_assert(std::same_as>>); + static_assert(std::same_as>>); } // sync-example-mapping-values-and-errors @@ -183,15 +197,15 @@ auto operator_and_composition() -> void auto result = a & b; - static_assert(std::same_as, fn::copack>>); + static_assert(std::same_as, fn::copack_for>>); } // sync-example-operator-and-composition // sync-example-cartesian-distribution auto test_cartesian_distribution() -> void { - constexpr fn::copack ab = A{}; - constexpr fn::copack cd = C{}; + constexpr fn::copack_for ab = A{}; + constexpr fn::copack_for cd = C{}; auto result1 = ab & cd; @@ -220,7 +234,7 @@ auto test_conjunction_with_identity_cluster() -> void static_assert(std::same_as); // Conjoining a choice causes distribution inside the carrier - fn::choice_for ch = 1.5; + fn::choice ch = 1.5; auto res3 = ex & ch; static_assert( std::same_as, fn::pack>, Error>>); @@ -252,7 +266,7 @@ auto test_disjoin() -> void auto result = fn::disjoin(a, b, fn::just{1.5}); // Because just cannot fail, the entire disjunction becomes total - static_assert(std::same_as>); + static_assert(std::same_as>); } // sync-example-test-disjoin @@ -264,7 +278,7 @@ auto sequential_bind() -> void { auto result = parse_numeric() | fn::and_then(load_user); - static_assert(std::same_as>>); + static_assert(std::same_as>>); } // sync-example-sequential-bind @@ -288,8 +302,8 @@ auto config_pipeline() -> void // The result exactly bounds both the successful paths and the error paths static_assert( - std::same_as, - fn::copack>>); + std::same_as, + fn::copack_for>>); } // sync-example-config-pipeline @@ -336,14 +350,14 @@ auto test_identity_transformation() -> void // Transforming a just with a callable returning a copack produces a choice auto mapped = j | fn::transform([](UserId) { return fn::copack_for{Missing{}}; }); - static_assert(std::same_as>); + static_assert(std::same_as>); } // sync-example-test-identity-transformation // sync-example-test-choice-mapping auto test_choice_mapping() -> void { - fn::choice_for ch{UserId{}}; + fn::choice ch{UserId{}}; constexpr auto mapper = fn::overload{[](UserId) { return fn::choice{Missing{}}; }, [](User) { return fn::choice{FilePath{}}; }}; @@ -356,7 +370,7 @@ auto test_choice_mapping() -> void // and_then joins and flattens them into a normalized superset choice auto bound = ch | fn::and_then(mapper); - static_assert(std::same_as>); + static_assert(std::same_as>); } // sync-example-test-choice-mapping @@ -373,12 +387,12 @@ constexpr auto test_laws() -> void constexpr fn::expected> ex{42}; // Functor Identity: mapping with identity yields the same value - auto id = [](auto v) { return v; }; + constexpr auto id = [](auto v) { return v; }; static_assert((ex | fn::transform(id)) == ex); // Monad Left Identity: pure(x) >>= f is equivalent to f(x) - auto pure = [](int v) { return fn::expected>{v}; }; - auto f = [](int v) { return fn::expected>{v * 2}; }; + constexpr auto pure = [](int v) { return fn::expected>{v}; }; + constexpr auto f = [](int v) { return fn::expected>{v * 2}; }; static_assert((pure(42) | fn::and_then(f)) == f(42)); } // sync-example-test-laws @@ -413,10 +427,13 @@ int main() test_conjunction_with_identity_cluster(); operator_or_composition(); test_disjoin(); + sequential_bind(); config_pipeline(); + test_explicit_lifting(fn::expected{User{}}, fn::optional{User{}}); test_identity_cross(); test_identity_transformation(); test_choice_mapping(); + (void)test_elimination(fn::expected>{UserId{}}); test_laws(); test_references(); return 0; diff --git a/include/fn/pack.hpp b/include/fn/pack.hpp index db9d70a6..690d633b 100644 --- a/include/fn/pack.hpp +++ b/include/fn/pack.hpp @@ -331,9 +331,22 @@ template <::std::size_t I, some_pack P> * @return TODO */ [[nodiscard]] constexpr auto as_pack() noexcept -> pack<> { return {}; } +// The unused leading pack absorbs explicit template arguments and the constraint rejects them: +// this overload is deduction-only (value-category preserving), the overload below serves spelled types +template + requires(sizeof...(Explicit) == 0) && (not some_in_place_type) + && detail::_initializable, T, Args...> +[[nodiscard]] constexpr auto as_pack(T &&src, Args &&...args) // + noexcept(detail::_nothrow_initializable, T, Args...>) -> pack +{ + return pack{FWD(src), FWD(args)...}; +} +// No element type is deduced: the explicit form names ALL of pack or is not viable +// (a partial spelling fails on arity); by-value parameters admit conversion at the call boundary +// (narrowing included) while still relocating rvalue arguments template requires(not some_in_place_type) && detail::_initializable, T, Args...> -[[nodiscard]] constexpr auto as_pack(T &&src, Args &&...args) // +[[nodiscard]] constexpr auto as_pack(::std::type_identity_t src, ::std::type_identity_t... args) // noexcept(detail::_nothrow_initializable, T, Args...>) -> pack { return pack{FWD(src), FWD(args)...}; From f168a2e4c2d35ad0f1de1d718956601bb7221179 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 24 Jul 2026 16:12:46 +0100 Subject: [PATCH 11/51] docs: Relax type-ordering in examples and refine pack/copack explanations * Relax type algebra checks in examples to use stable choice instead of choice_for * Polish copack, copack_for, choice, and choice_for terminology for strict ordering * Document and illustrate value-category preservation in deduction-only as_pack * Document and verify by-value decay and multi-argument coercion in explicit as_pack * Synchronize all code regions in TYPE_ALGEBRA.md with examples/type_algebra/main.cpp * Document and verify the compile-time behavior of vacuous or_else recovery Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 19 +++++++++++++++++++ examples/type_algebra/main.cpp | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 0d5ad3fd..13b94199 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -752,6 +752,25 @@ Monadic operations behave naturally around this identity cluster: - **Elimination fallbacks (`value_or`)**: Strictly rejected on `just` and `choice` since they can never fail, rendering any fallback redundant and dead. On `expected>`, `value_or` is vacuously well-formed, but the fallback branch is optimized away as unreachable (to allow generic code on `expected` to compile without errors) - **Neutral observation (`inspect`, `discard`)**: Fully supported and behave normally. +> [!NOTE] +> +> ### Note — the vacuous `or_else` asks nothing +> +> This compiles (as illustrated below), even though the "callback" is a plain `int` (not a callable at all). In contrast, if the error grade is inhabited (or on `optional`, where the empty state is a genuine inhabited state), `or_else(42)` is loudly rejected. +> +> This behavior is mathematically rigorous. The `or_else` operation evaluates the callback over the error alternatives. Over the uninhabited `copack<>`, there are zero alternatives, so the underlying fold has zero inputs. The operation trivially collapses to the identity mapping, the callback contributes nothing, and no questions about the callback — not even invocability — are formable. Demanding a constraint on the callback would be an arbitrary invention rather than a logical derivation. +> +> This serves a load-bearing design principle: generic code remains closed under all error grades. If `or_else` were rejected on `expected>`, a recovery step would become ill-formed simply because an upstream stage statically proved that failure is impossible, breaking generic composition. Instead, the recovery step stays writable everywhere—and does nothing where failure is impossible. + + +```cpp +auto test_vacuous_or_else() -> void +{ + using type = decltype(fn::expected>{} | fn::or_else(std::declval())); + static_assert(std::same_as>>); +} +``` + > [!TIP] > > ### Mathematical note — canonical state-shape isomorphisms diff --git a/examples/type_algebra/main.cpp b/examples/type_algebra/main.cpp index 31a15ffa..4bab7873 100644 --- a/examples/type_algebra/main.cpp +++ b/examples/type_algebra/main.cpp @@ -342,6 +342,14 @@ auto test_identity_cross() -> void } // sync-example-test-identity-cross +// sync-example-vacuous-or-else +auto test_vacuous_or_else() -> void +{ + using type = decltype(fn::expected>{} | fn::or_else(std::declval())); + static_assert(std::same_as>>); +} +// sync-example-vacuous-or-else + // sync-example-test-identity-transformation auto test_identity_transformation() -> void { @@ -431,6 +439,7 @@ int main() config_pipeline(); test_explicit_lifting(fn::expected{User{}}, fn::optional{User{}}); test_identity_cross(); + test_vacuous_or_else(); test_identity_transformation(); test_choice_mapping(); (void)test_elimination(fn::expected>{UserId{}}); From ea37589cf95a1cdff1ff991fe296c60181c45d7c Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 24 Jul 2026 16:30:07 +0100 Subject: [PATCH 12/51] docs: Improve transition flow into the vacuous or_else example * Reposition code block outside of the markdown blockquote for clean synchronization * Rewrite introductory transition sentence to flow naturally into test_vacuous_or_else() * Keep math notes and closure principle explanations fully intact * Upgrade sync_type_algebra_examples.py to support quoted code examples Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 22 ++++++++++++---------- scripts/sync_type_algebra_examples.py | 23 +++++++++++++++++------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 13b94199..10f88dd3 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -756,21 +756,23 @@ Monadic operations behave naturally around this identity cluster: > > ### Note — the vacuous `or_else` asks nothing > -> This compiles (as illustrated below), even though the "callback" is a plain `int` (not a callable at all). In contrast, if the error grade is inhabited (or on `optional`, where the empty state is a genuine inhabited state), `or_else(42)` is loudly rejected. +> For instance, the following function compiles successfully, even though the recovery handler is a plain `int` (not a callable at all): +> +> +> ```cpp +> auto test_vacuous_or_else() -> void +> { +> using type = decltype(fn::expected>{} | fn::or_else(std::declval())); +> static_assert(std::same_as>>); +> } +> ``` +> +> In contrast, if the error grade is inhabited (or on `optional`, where the empty state is a genuine inhabited state), `or_else(42)` is loudly rejected. > > This behavior is mathematically rigorous. The `or_else` operation evaluates the callback over the error alternatives. Over the uninhabited `copack<>`, there are zero alternatives, so the underlying fold has zero inputs. The operation trivially collapses to the identity mapping, the callback contributes nothing, and no questions about the callback — not even invocability — are formable. Demanding a constraint on the callback would be an arbitrary invention rather than a logical derivation. > > This serves a load-bearing design principle: generic code remains closed under all error grades. If `or_else` were rejected on `expected>`, a recovery step would become ill-formed simply because an upstream stage statically proved that failure is impossible, breaking generic composition. Instead, the recovery step stays writable everywhere—and does nothing where failure is impossible. - -```cpp -auto test_vacuous_or_else() -> void -{ - using type = decltype(fn::expected>{} | fn::or_else(std::declval())); - static_assert(std::same_as>>); -} -``` - > [!TIP] > > ### Mathematical note — canonical state-shape isomorphisms diff --git a/scripts/sync_type_algebra_examples.py b/scripts/sync_type_algebra_examples.py index c8921e51..92078ab5 100755 --- a/scripts/sync_type_algebra_examples.py +++ b/scripts/sync_type_algebra_examples.py @@ -52,18 +52,29 @@ # 2. Read and synchronize TYPE_ALGEBRA.md doc_text = doc_path.read_text(encoding="utf-8") -# We find followed by ```cpp ``` -# and replace the content with the region. +# We find optionally prefixed by blockquote indicators like '> ' +# followed by ```cpp ```, and replace it while preserving the prefix on every line. def replace_snippet(match): - name = match.group(1) + prefix = match.group(1) + name = match.group(2) if name not in regions: sys.stderr.write(f"Warning: no matching region in main.cpp for sync-example-{name}\n") return match.group(0) # Keep unchanged - return f"\n```cpp\n{regions[name]}\n```" + # Prefix each line of the synchronized example block if we are inside a blockquote + region_lines = regions[name].splitlines() + if prefix: + prefixed_region = "\n".join(f"{prefix}{line}" if line.strip() else prefix.rstrip() for line in region_lines) + else: + prefixed_region = "\n".join(region_lines) -# Match: followed by whitespace and ```cpp ... ``` -pattern = re.compile(r"\s*```cpp\n.*?```", re.DOTALL) + return f"{prefix}\n{prefix}```cpp\n{prefixed_region}\n{prefix}```" + +# Match: optional blockquote prefix (e.g., '> '), comment, opening fence, body, and closing fence +pattern = re.compile( + r"^([ >]*?)\s*?\n[ >]*?```cpp\n(.*?)\n[ >]*?```", + re.MULTILINE | re.DOTALL +) updated_text = pattern.sub(replace_snippet, doc_text) # Clean up trailing whitespace in the document (like pre-commit trailing whitespace check does) From b7c187c1fe371625ec852e1a6d88af9272305bf7 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 24 Jul 2026 18:39:35 +0100 Subject: [PATCH 13/51] docs: Restructure carriers and payloads, add cross-carrier bridging * Group all four computation carriers in Section 3 and sum/product payloads in Section 4 * Unified and document Success-Path and Failure-Path bridging in Section 3 * Illustrate multi-alternative choice to optional bridging with heterogeneous success join * Fix stale Section references throughout TYPE_ALGEBRA.md * Synchronize all code fences with examples/type_algebra/main.cpp Assisted-by: Gemini:gemini-3.6-flash --- TYPE_ALGEBRA.md | 147 +++++++++++++++++++++++---------- examples/type_algebra/main.cpp | 34 ++++++++ 2 files changed, 138 insertions(+), 43 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 10f88dd3..a85b6c04 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -118,7 +118,7 @@ The result contains a `pack` (a flat, tuple-like product) of the successful valu ### The Two Cooperating Mechanisms -Behind these highly precise compiled types are two independent mechanisms that cooperate to derive and eliminate these shapes: +Behind these precise compiled types are two independent mechanisms that cooperate to derive and eliminate these shapes: 1. **Type algebra** records and normalizes the exact stored C++ types using `pack` and `copack` as you compose operations. 2. **The application protocol** uses `apply` and ordinary C++ overload resolution to unpack those stored values and route them to your functions or lambdas. @@ -218,7 +218,7 @@ To invoke the algebra, you use the opt-in mechanisms provided by the library: - Direct construction of `pack` and `copack_for`. - Explicit conversions via `fn::as_pack` and `fn::as_copack`. -- Member helpers for explicit type lifting (detailed in Section 8). +- Member helpers for explicit type lifting (detailed in Section 9). If a side is already a `copack` or `pack`, forwarding it behaves naturally without nesting. @@ -243,7 +243,88 @@ When a callable supplied to `and_then` needs to produce an error grade, it can e > C++ types do not form a strict category due to compiler-specific equivalence relations, but `libfn` emulates these properties by enforcing type-level canonical flattening and deduplication. > > -## 3. The vocabulary types +## 3. The computation carriers + +To model computation and manage control flow (success, failure, alternatives, and empty states), `libfn` uses **computation carriers** (often called "monadic types"). The library defines exactly four carrier families, divided by their fallibility and payload capacity: + +### The fallible carriers + +- **`optional`** (representing $T + 1$): A carrier that either holds a successful value of type `T` or is empty (`std::nullopt`). +- **`expected`** (representing $T + E$): A carrier that either holds a successful value of type `T` or an error of type `E`. + +*(Note: `libfn` provides highly optimized, standards-conforming polyfills of `std::optional` and `std::expected` under the `pfn` namespace for C++20 compilers, while the `fn` namespace extends them.)* + +### The infallible (identity) carriers + +- **`just`**: Always contains a single successful value of type `T`. +- **`choice`**: Always contains one of several selected alternatives, representing the complete state space of the computation. +- **`expected>`** (representing $T + 0 \cong T$): Symmetrically to `just`, this carrier can never fail because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. + +Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. + +> [!NOTE] +> +> ### Note — `just>` is spelled `choice` +> +> A programmer might be tempted to represent a never-failing, multi-alternative computation by nesting a coproduct inside an identity carrier, spelling it `just>`. In `libfn`'s type algebra, this is precisely the space filled by `choice`. Structurally, `choice` is equivalent to a `just` container of a `copack`, providing a single-layer carrier that represents a never-failing computation over a coproduct. +> +> In fact, attempting to instantiate `just>` will trigger a compile-time static assertion failure inside `just`, explicitly warning the programmer: `"a just over a copack is spelled choice"`. + +These carriers impose constraints on their payloads, but reference payloads are broadly supported where sound. For example, `optional` is supported and well-defined. + +### Carriers have control flow; raw data does not + +It is vital to recognize that raw type algebraic constructs—such as a product `std::tuple` or a sum `std::variant`—are purely passive data layouts. They contain no intrinsic control flow, no concept of short-circuiting, and no built-in notion of "success" versus "failure." + +To compose computations, we must wrap these values inside **computation carriers**. When we perform product composition (conjunction) or sum composition (disjunction) later in this document, we are not combining raw data; we are composing carriers. The carrier manages the propagation of success values and the short-circuiting of failures. + +### Carrier Bridging: Interoperable Pipelines + +Because these carriers represent different computational contexts, pipelines often need to transition between them. `libfn` licenses explicit **cross-carrier bridging** via pipeline-scoped operations using `operator|`: + +1. **Failure-Path Bridging (`or_else` between Fallible Carriers)**: + Standard fallible carriers can bridge to each other on their error/empty recovery paths via `or_else` (e.g., `expected` to `optional`, or vice versa). This is safe because on the success path, the successful value is preserved and bypasses the recovery callback entirely. The transition only occurs on the handled failure branch, allowing you to gracefully convert a missing value into a concrete error, or decay a detailed error into an empty state: + + +```cpp +auto test_failure_bridge(fn::expected ex, fn::optional opt) -> void +{ + // Fallible carriers can bridge to each other on the failure/empty recovery path + auto expected_to_optional = ex | fn::or_else([](IoError) { return fn::optional{}; }); + static_assert(std::same_as>); + + auto optional_to_expected = opt | fn::or_else([]() { return fn::expected{100}; }); + static_assert(std::same_as>); +} +``` + +1. **Success-Path Bridging (`and_then` from Identity to Fallible)**: + Identity carriers can safely bridge to any fallible carrier via `and_then`. Since an identity carrier (like `just` or `choice`) is statically proven to be infallible, transitioning to `optional` or standard `expected` merely introduces potential failure downstream. No pre-existing failure state is discarded because none can exist upstream: + + +```cpp +auto test_success_bridge() -> void +{ + fn::just j{1}; + + // An identity carrier can bridge to fallible carriers on the success path + auto to_opt = j | fn::and_then([](int i) { return fn::optional{i}; }); + static_assert(std::same_as>); + + auto to_exp = j | fn::and_then([](int i) { return fn::expected{i}; }); + static_assert(std::same_as>); + + // Bridging a multi-alternative choice to fallible optional with heterogeneous success join: + auto choice_to_opt = fn::choice_for{true} + | fn::and_then(fn::overload{[](int) -> fn::optional { return {'a'}; }, + [](bool) -> fn::optional { return {2L}; }}); + static_assert(std::same_as>>); +} +``` + +## 4. The sum and product payloads: pack and copack + +While the computation carriers manage control flow and fallibility, modeling more complex algebraic structures—such as multi-field products or multi-alternative disjoint sums—requires specialized payload types. `libfn` provides two core vocabulary types for this: ### pack: all fields are present @@ -327,31 +408,7 @@ auto test_copack() -> void A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. The same discipline explains why direct `get` extraction is disallowed for multi-alternative `copack` types: allowing partial extraction would bypass compile-time exhaustiveness guarantees. -### The computation carriers - -To model computation, `libfn` uses carrier types (often referred to as "monadic" contexts in functional programming): - -- `just`: Always contains a single successful value. -- `optional`: Contains a value or is empty. -- `expected`: Contains a value or an exact error type. -- `choice`: Always contains one of several selected alternatives, representing the complete state space of the computation. - -Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. - -**Rule:** A carrier does not need another carrier for multidispatch. Inside an `expected` or `optional`, store your alternative states as `copack`. Use `choice` only when those alternatives are themselves the outer, never-failing computation. - -> [!NOTE] -> -> ### Note — `just>` is spelled `choice` -> -> -> A programmer might be tempted to represent a never-failing, multi-alternative computation by nesting a coproduct inside an identity carrier, spelling it `just>`. In `libfn`'s type algebra, this is precisely the space filled by `choice`. Structurally, `choice` is equivalent to a `just` container of a `copack`, providing a single-layer monadic carrier that represents a never-failing computation over a coproduct. -> -> In fact, attempting to instantiate `just>` will trigger a compile-time static assertion failure inside `just`, explicitly warning the programmer: `"a just over a copack is spelled choice"`. - -These carriers impose constraints on their payloads, but reference payloads are broadly supported where sound. For example, `optional` is supported and well-defined. - -## 4. Mapping values and errors +## 5. Mapping values and errors Mapping allows you to change the contained data without altering the structural success/failure shape of the computation. `libfn` uses `transform` (functor map) to operate on the successful channel, and `transform_error` for the error channel. @@ -393,7 +450,7 @@ Key principles of mapping: > > In `libfn`, `transform` implements this morphism mapping ($fmap$). Functorial action on the initial object $0$ (the uninhabited `copack<>`) is vacuous: since there are no morphisms originating from $0$ (except the unique initial morphism), mapping over an empty alternative set is vacuously true. The compiler leverages this by optimizing `transform` on `optional>` into a static no-op. > -## 5. Product composition with operator& (conjunction) +## 6. Product composition with operator& (conjunction) Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results. @@ -440,7 +497,7 @@ auto test_cartesian_distribution() -> void ### Conjunction with the Identity Cluster -When performing product composition (`operator&`), you can combine fallible carriers (like `expected` or `optional`) with any member of the **identity cluster** (detailed in Section 9): +When performing product composition (`operator&`), you can combine fallible carriers (like `expected` or `optional`) with any member of the **identity cluster** (detailed in Section 10): - **Errors are unaffected**: Because identity cluster operands can never fail, they add no new types or terms to the result's error channel. The error side of the fallible operand is preserved exactly (whether plain or copack-graded). - **Value bundling**: The value of the identity cluster operand is conjoined with the fallible operand's value channel into a `fn::pack`. @@ -482,7 +539,7 @@ auto test_conjunction_with_identity_cluster() -> void > This is precisely the Cartesian distribution of `pack` over `copack` implemented statically by `libfn`. > - **Error Accumulation**: For `expected`, the error grades form a union, which corresponds to the monoidal composition of effects in the underlying monoid $(\mathcal{E}, \cup, \emptyset)$. > -## 6. Sum composition with operator| (disjunction) +## 7. Sum composition with operator| (disjunction) Simultaneous sum composition combines alternative computations. By evaluating `a | b`, you attempt the left computation `a`. If it succeeds, its result is preserved. If it fails, you evaluate the right computation `b` as a fallback. @@ -509,7 +566,7 @@ The runtime and compile-time semantics of disjunction are exact: - Because the overall disjunction only fails if *both* operands fail, the error channel represents the product of both errors. This is recorded positionally inside `fn::pack`. - If both operands contain graded error sets (`copack`s of errors), the errors distribute through the product: $(El + Er) \times (El') \to (El \times El') + (Er \times El')$. This yields a `copack` of `pack`s, representing all combinations of failure states. - **Total Disjunction and the Identity Cluster**: - - If at least one operand belongs to the **identity cluster** (detailed in Section 9), the disjunction is guaranteed to never fail at runtime. + - If at least one operand belongs to the **identity cluster** (detailed in Section 10), the disjunction is guaranteed to never fail at runtime. - The error side gains an uninhabited factor (`copack<>`), which collapses the error channel entirely and prevents the result from failing. - The result is folded into a non-failing carrier of the identity cluster: a single-valued `just` if there is only one successful type, or `choice` if the sum is heterogeneous. @@ -545,7 +602,7 @@ auto test_disjoin() -> void > $$E \times 0 \cong 0$$ > This mathematical property forces the error channel to collapse, rendering the entire disjunction total (never-failing) and folding the result into the identity cluster. > -## 7. Sequential composition with and_then +## 8. Sequential composition with and_then Sequential composition chains dependent operations where the success of one feeds the input of the next. In `libfn`, this is achieved using `and_then` (monadic bind). @@ -600,9 +657,9 @@ static_assert(!fn::same_kind, fn::expected > $$\mu \circ M(\mu) = \mu \circ \mu_M \quad \text{and} \quad \mu \circ M(\eta) = id_M = \mu \circ \eta_M$$ > -> In C++, `and_then` implements the bind operation, while `transform` implements the endofunctor map $M(f)$. These laws are verified statically under constant evaluation in Section 13. +> In C++, `and_then` implements the bind operation, while `transform` implements the endofunctor map $M(f)$. These laws are verified statically under constant evaluation in Section 14. > -## 8. Graded expected: exact error sets +## 9. Graded expected: exact error sets `expected` grading provides exactly bounded error sets. When an outer computation holds a coproduct of successful values, and each value requires a different operation to proceed, `libfn` derives a single, normalized `expected` shape. @@ -710,7 +767,7 @@ In practice, `expected>` acts as **the graded gateway** to start > > operating exactly at the neutral identity element $I = \emptyset$ of the error pomonoid. Since $\emptyset \cup F = F$, initiating a computation with this unit trigger ensures that the composition's grade accumulates subsequent effects precisely without introducing spurious terms—making it the rigorous monoidal starting gateway. > -## 9. The identity cluster +## 10. The identity cluster Certain operations behave like an identity functor across different carriers. Because some states correspond structurally, `libfn` licenses specific cross-carrier behavior to prevent redundant boilerplate. @@ -743,9 +800,13 @@ The bind operation adopts the carrier family of the provided callback. However, Furthermore, fallible types like `expected` (with inhabited error states) and `optional` cannot indiscriminately switch to other carriers, because doing so would risk silently discarding an inhabited state. +### Success-Path Bridging + +As detailed and illustrated in Section 3, while standard fallible carriers cannot change families on the success path, **identity carriers are licensed to bridge to any fallible carrier** via the pipeline `and_then`. Because identity carriers (like `just`, `choice`, or `expected>`) are statically proven infallible, transitioning to a fallible carrier simply introduces potential failure downstream without discarding any pre-existing error or empty state. + Monadic operations behave naturally around this identity cluster: -- **Success mapping (`transform`)**: Remains meaningful and stays inside the nominal carrier family when using member functions. However, when using the pipeline `operator|`, returning a `copack` from `fn::transform` on an identity carrier automatically promotes the result to `choice` (as detailed in Section 10). +- **Success mapping (`transform`)**: Remains meaningful and stays inside the nominal carrier family when using member functions. However, when using the pipeline `operator|`, returning a `copack` from `fn::transform` on an identity carrier automatically promotes the result to `choice` (as detailed in Section 11). - **Sequential binding (`and_then`)**: Allows cross-carrier transitions *within* the identity cluster (e.g., `just` to `expected>`) when using pipeline-scoped `fn::and_then`. - **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable (to allow generic code on `expected` to compile) - **Short-circuiting (`fail`, `filter`)**: Strictly rejected for all identity cluster carriers, because no failure state (an inhabited error or empty state) can possibly be constructed from a never-failing identity context. @@ -786,13 +847,13 @@ Monadic operations behave naturally around this identity cluster: > > While these objects are canonically isomorphic, C++ enforces strong nominal type boundaries. `libfn` respects this by refusing implicit conversions (which would pollute the compiler's overload resolution space), choosing instead to expose these isomorphisms through **licensed binds** (cross-carrier pipeline functors) that preserve the information-theoretic equivalence without introducing implicit conversion cycles. > -## 10. choice: identity over a coproduct +## 11. choice: identity over a coproduct The `choice` carrier represents a computation that always succeeds by selecting one of several alternatives. Structurally, it serves as the single-layer carrier for coproduct states, avoiding the invalid nested `just>` representation discussed in Section 3. ### Decoupling via Pipeline Functors -As established in Section 9, transitions within the identity cluster are strictly restricted to pipeline-scoped functors to preserve decoupling between carriers. +As established in Section 10, transitions within the identity cluster are strictly restricted to pipeline-scoped functors to preserve decoupling between carriers. For example, a pipeline-scoped `fn::transform` on a `just` that returns a `copack` is promoted automatically to a `choice`: @@ -858,7 +919,7 @@ Bare-value callbacks are rejected by `choice`'s `and_then`. > - **Bind**: Composes callbacks by mapping and explicitly flattening via `join`. This explicit step grants control over *when* flattening occurs, turning a loose collection of types into a rigorous Monad. > > -## 11. Elimination and multidispatch +## 12. Elimination and multidispatch Once your computation shapes are fully derived, you must eliminate the structure to yield an ordinary C++ value. This is done via `apply` or `apply_r`. @@ -929,7 +990,7 @@ When you eliminate a carrier using `apply_type`, the active handler receives an > > Carrier elimination (`apply_type`) preserves the canonical injections by supplying explicit state tags (such as `std::in_place` or `std::in_place_type`) alongside the payload. This ensures that the caller retains the exact information of *which* injection morphism placed the value into the structure. > -## 12. The monadic operations map +## 13. The monadic operations map This is a concise reference for `libfn`'s operations, organized by channel and effect: @@ -970,7 +1031,7 @@ To reason about how these operations affect the type algebra of your computation - **Error-side monadic operations** (like `transform_error`, `or_else`, `recover`, and `inspect_error`) are only well-formed if the carrier has an appropriate error or empty side (and are rejected on identity carriers like `just` or `choice`). -## 13. Laws as C++ equalities +## 14. Laws as C++ equalities The algebraic laws governing `libfn` shapes are verified by the compiler where structural capabilities permit. For instance, you can observe functor identity and monad left identity in `constexpr` contexts: @@ -1001,7 +1062,7 @@ Other properties hold structurally: - **Identity cluster binds**: Laws hold across `just`, `choice`, and `expected>` via the canonical payload-preserving state-shape correspondence. -## 14. C++ mechanics that preserve the algebra +## 15. C++ mechanics that preserve the algebra To make the algebraic model reliable in everyday C++, `libfn` uses extensive compiler mechanisms to reject malformed usage and preserve performance properties. diff --git a/examples/type_algebra/main.cpp b/examples/type_algebra/main.cpp index 4bab7873..96e3c8cb 100644 --- a/examples/type_algebra/main.cpp +++ b/examples/type_algebra/main.cpp @@ -342,6 +342,38 @@ auto test_identity_cross() -> void } // sync-example-test-identity-cross +// sync-example-test-success-bridge +auto test_success_bridge() -> void +{ + fn::just j{1}; + + // An identity carrier can bridge to fallible carriers on the success path + auto to_opt = j | fn::and_then([](int i) { return fn::optional{i}; }); + static_assert(std::same_as>); + + auto to_exp = j | fn::and_then([](int i) { return fn::expected{i}; }); + static_assert(std::same_as>); + + // Bridging a multi-alternative choice to fallible optional with heterogeneous success join: + auto choice_to_opt = fn::choice_for{true} + | fn::and_then(fn::overload{[](int) -> fn::optional { return {'a'}; }, + [](bool) -> fn::optional { return {2L}; }}); + static_assert(std::same_as>>); +} +// sync-example-test-success-bridge + +// sync-example-test-failure-bridge +auto test_failure_bridge(fn::expected ex, fn::optional opt) -> void +{ + // Fallible carriers can bridge to each other on the failure/empty recovery path + auto expected_to_optional = ex | fn::or_else([](IoError) { return fn::optional{}; }); + static_assert(std::same_as>); + + auto optional_to_expected = opt | fn::or_else([]() { return fn::expected{100}; }); + static_assert(std::same_as>); +} +// sync-example-test-failure-bridge + // sync-example-vacuous-or-else auto test_vacuous_or_else() -> void { @@ -439,6 +471,8 @@ int main() config_pipeline(); test_explicit_lifting(fn::expected{User{}}, fn::optional{User{}}); test_identity_cross(); + test_success_bridge(); + test_failure_bridge(fn::expected{}, fn::optional{}); test_vacuous_or_else(); test_identity_transformation(); test_choice_mapping(); From 12077076d2f634fba30fe6dde8b02f2664d5b640 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 24 Jul 2026 19:24:34 +0100 Subject: [PATCH 14/51] Minor improvements --- TYPE_ALGEBRA.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index a85b6c04..1983475d 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -189,7 +189,7 @@ auto test_copack_set_semantics() -> void > > To enforce strict, mathematically sound set semantics at compile time, `libfn` defines a single, strict canonical representation and actively rejects any instantiation that diverges from it: > -> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in strict lexicographical order (the order defined by C++26 `std::type_order`, which `libfn` emulates for pre-C++26 compilers). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` lexicographically precedes `B`) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** +> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict order (defined by C++26 `std::type_order`, which `libfn` emulates — with many limitations — for pre-C++26 compilers). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` lexicographically precedes `B`) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** > - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and lexicographical sorting automatically, and resolving directly to the validated canonical `copack` type. > > To the C++ programmer, they can often be treated as equivalent in APIs because `copack_for` (as a type alias) always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. When writing types out manually, `copack` requires the types to already be in strict canonical order, whereas `copack_for` handles arbitrary, out-of-order, or duplicated lists. @@ -909,7 +909,7 @@ Bare-value callbacks are rejected by `choice`'s `and_then`. > Categorically, `copack` is an object-level **coproduct** (disjoint sum $\bigoplus T_i$), whereas `choice` is a **monad** representing a coproduct-bearing computation context. > > 1. **`copack` is self-flattening (not a monad)**: -> Naked sums are naturally self-flattening (e.g., $(A + B) + C \cong A + B + C$). This property makes nesting impossible ($M \circ M(T) \cong M(T)$), rendering the structural `join`/`flatten` operation a trivial identity map. Because mapping and binding collapse into the same operation, self-flattening structures lose the structural depth needed to satisfy the Monad identity and associativity laws. Symmetrical in its alternatives, `copack` is pure sum data, not an endofunctor. +> Naked sums are naturally self-flattening (e.g., $(A + B) + C \cong A + B + C$), and `libfn` enforces it syntactically: a nested `copack` is ill-formed by design. But the multiplication $\mu_A : M(M(A)) \to M(A)$ presupposes that $M \circ M$ is expressible as a type, so *join* has no domain to act on. The constraint is intentional: it allows `transform` on `copack` to collapse every branch's results into a flat, deduplicated set instead of an ever-nesting type. > > 2. **`choice` is the monad (the "structural suspend button")**: > To restore monad laws, the monadic carrier `choice` wraps the sum in an "identity layer" to preserve structural depth: `choice>` $\ne$ `choice`. This "structural suspend button" holds eager flattening in check. From 78fdbc6a705b432335c6752ed080741c0314c845 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 24 Jul 2026 22:43:25 +0100 Subject: [PATCH 15/51] Review TYPE_ALGEBRA content for contradictions and repetition Split carrier bridging by source: section 3 keeps fallible-to-fallible, section 10 takes every bridge from an identity carrier. Drop section 6's duplicate operator& example in favour of section 1's, and widen the shared example-types region to the types the later fences use. Each prose change carries a `` annotation recording the mechanism and its evidence, for the author's review. Assisted-by: Claude:claude-opus-5 --- TYPE_ALGEBRA.md | 544 ++++++++++++++++++++++++++------- examples/type_algebra/main.cpp | 15 +- 2 files changed, 430 insertions(+), 129 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 1983475d..1c1edb4c 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1,8 +1,17 @@ # Type algebra and functional composition in libfn -The `libfn` library is a C++20 functional programming framework that lets the compiler derive both the values and the complete static shape of a computation. Instead of relying on type erasure, exceptions, or monolithic sum types, `libfn` tracks the precise algebraic combinations of success, alternative, and error states during composition. - -The library operates on a few core vocabulary types: + +`libfn` is a C++20 functional programming library that lets the compiler derive the complete static shape of a computation alongside its values. Rather than collapsing failure into one wide error type, `libfn` tracks the precise algebraic combinations of success, alternative, and error states during composition. + + +The library operates on two payload types and four computation carriers: - `pack`: Product type containing all fields. - `copack`: Canonical coproduct containing exactly one alternative. @@ -11,16 +20,39 @@ The library operates on a few core vocabulary types: - `choice`: Never-failing computation holding one of several alternatives. - `just`: Identity computation always yielding a single value. -Composition operations include `transform` (mapping), `and_then` (sequential monadic binding), `operator&` (conjunction / simultaneous product composition), `operator|` (disjunction / simultaneous sum composition), the n-ary folds `fn::conjoin` and `fn::disjoin`, and `apply` (multidispatch elimination). + +Composition operations include `transform` (mapping), `transform_error` (error mapping), `and_then` (sequential monadic binding), `or_else` (recovery), `operator&` (conjunction / simultaneous product composition), `operator|` (disjunction / simultaneous sum composition), and the n-ary folds `fn::conjoin` and `fn::disjoin`. Elimination is `apply` (multidispatch). ### Member vs. Pipeline Syntax -Most operations are exposed in two forms: + +Some operations are exposed in two forms: -- **Member functions** (e.g., `.transform()`, `.and_then()`, `.apply()`) called directly on a carrier (e.g., `ex.transform(f)`). +- **Member functions** (e.g., `.transform()`, `.and_then()`, `.apply()`) called directly on a carrier or payload (e.g., `ex.transform(f)`, `cp.apply(f)`). - **Pipeline functors** in namespace `fn` (e.g., `fn::transform`, `fn::and_then`) applied via `operator|` (e.g., `ex | fn::transform(f)`). -Freestanding `fn::apply` acts as a utility (like `std::apply`) to unpack any tuple-like or pack-like structure. +The remaining pipeline functors — `recover`, `fail`, `filter`, `inspect`, `inspect_error`, `discard` — have no member spelling, and `apply`, `apply_r` and `apply_type` have no pipeline spelling. + + +`operator|` carries two meanings, told apart by its right operand: a pipeline functor on the right feeds the carrier into that operation, while another carrier on the right is disjunction (Section 7). + + +Freestanding `fn::apply(f, args...)` is the general multidispatch entry point: it accepts any mix of scalars, tuple-like structures, `pack`s and `copack`s, unpacking products and dispatching over alternatives in a single call. The `std::apply`-shaped two-argument utility is `pfn::apply`. In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected`, `pack`) when referring to both forms or core vocabulary types generally. @@ -31,6 +63,14 @@ Although different types can behave identically during application, they remain To illustrate these concepts, the examples in this document use a reusable set of value and error types: ```cpp +struct Error {}; +struct OtherError {}; + +struct A {}; +struct B {}; +struct C {}; +struct D {}; + struct UserId {}; struct User {}; struct FilePath {}; @@ -77,7 +117,13 @@ Standard monads are rigid: an `expected` requires every step in a pipeline A **graded monad** relaxes this restriction. Each operation is indexed by a "grade"—a set representing its specific possible errors (its "effects"). As you chain operations, the compiler automatically unions these grades. -The resulting error type is **graded**: it dynamically expands (or narrows during recovery) to match the *exact* subset of errors possible in the compiled path, providing strict static effect tracking (subeffecting) with zero boilerplate. + +The resulting error type is **graded**: it expands (or narrows during recovery) to match the *exact* subset of errors possible in the compiled path, providing strict static effect tracking (subeffecting) with zero boilerplate. Grading is opt-in: a `copack` on the error side is what enrols an `expected` in this union arithmetic; a plain `expected` keeps the rigid single-error contract. > [!TIP] > @@ -123,7 +169,11 @@ Behind these precise compiled types are two independent mechanisms that cooperat 1. **Type algebra** records and normalizes the exact stored C++ types using `pack` and `copack` as you compose operations. 2. **The application protocol** uses `apply` and ordinary C++ overload resolution to unpack those stored values and route them to your functions or lambdas. -To handle multiple alternative paths smoothly inside `apply`, the library provides the `fn::overload` utility. This utility constructs a unified overload set from a collection of otherwise unrelated lambdas, routing the unpacked values to the correct handler at compile time via C++ overload resolution. + +To route multiple alternative paths inside `apply`, the library provides `fn::overload`, which fuses unrelated lambdas into a single overload set. These derived types are the actual explanation of the library's design, not an internal template-metaprogramming implementation detail. Understanding the precise algebraic rules of this type algebra and the mechanics of application is key to mastering the library. @@ -138,8 +188,15 @@ To derive strict programmatic shapes, `libfn` uses an algebraic vocabulary over These states can also be used to express the standard vocabulary types: -- `std::optional` ≅ **1 + T** (It is either empty/unit or contains `T`, similar to `copack`) -- `std::expected` ≅ **T + E** (It contains either success `T` or error `E`, similar to `copack>`) + +- `std::optional` ≅ **1 + T** (It is either empty/unit or contains `T`, similar to `copack_for`) +- `std::expected` ≅ **T + E** (It contains either success `T` or error `E`, similar to `copack_for>`) The symbol ≅ indicates an equivalent state shape (an information-level correspondence), not `std::same_as`. `std::optional` is its own distinct C++ type, but algebraically, it behaves as `1 + T`. @@ -189,8 +246,14 @@ auto test_copack_set_semantics() -> void > > To enforce strict, mathematically sound set semantics at compile time, `libfn` defines a single, strict canonical representation and actively rejects any instantiation that diverges from it: > -> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict order (defined by C++26 `std::type_order`, which `libfn` emulates — with many limitations — for pre-C++26 compilers). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` lexicographically precedes `B`) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** -> - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and lexicographical sorting automatically, and resolving directly to the validated canonical `copack` type. +> +> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict total order over types (derived from the compiler's own spelling of each type; a build targeting C++26 uses `std::type_order` instead, which is why the two orders form separate ABIs). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` precedes `B` in that order) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** +> - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and canonical sorting automatically, and resolving directly to the validated canonical `copack` type. > > To the C++ programmer, they can often be treated as equivalent in APIs because `copack_for` (as a type alias) always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. When writing types out manually, `copack` requires the types to already be in strict canonical order, whereas `copack_for` handles arbitrary, out-of-order, or duplicated lists. > @@ -203,7 +266,14 @@ The laws governing `copack` are: - **Idempotent**: Duplicate types are collapsed into one. - **Identity**: `copack<>` acts as the union unit (adding `copack<>` changes nothing). -Because canonical ordering collapses identical types, distinct types must never be silently lost if the ordering cannot distinguish them. Consequently, types combined into a `copack` should be strongly typed tag structs or distinct domain objects, not generic primitives whose semantic meaning depends on their position (e.g., `copack_for` collapses to `copack`, discarding the positional distinction). + +Idempotence means a set cannot carry positional meaning: `copack_for` is one alternative, `copack`, not two. Types combined into a `copack` should therefore be strongly typed tag structs or distinct domain objects, never generic primitives whose meaning depends on where they sit. Two *distinct* types that the canonical ordering cannot tell apart are a different matter: the library rejects them outright rather than merging them, so nothing is ever silently lost. ### The algebra is strictly opt-in @@ -220,12 +290,12 @@ To invoke the algebra, you use the opt-in mechanisms provided by the library: - Explicit conversions via `fn::as_pack` and `fn::as_copack`. - Member helpers for explicit type lifting (detailed in Section 9). -If a side is already a `copack` or `pack`, forwarding it behaves naturally without nesting. - -When a callable supplied to `and_then` needs to produce an error grade, it can explicitly lift an expected value into one with a copack error: - -- A callback returning `expected>` selects `copack` as the exact result spelling. -- It does **not** authorize a union with a completely different, unrelated plain error type. Union grading requires the original outer `expected`'s error side to also be a `copack`. + +If a side is already a `copack` or `pack`, forwarding it behaves naturally without nesting. A `copack` on an `expected`'s error side is the opt-in to error-set unioning; Section 9 gives the exact promotion rules. > [!TIP] > @@ -252,13 +322,19 @@ To model computation and manage control flow (success, failure, alternatives, an - **`optional`** (representing $T + 1$): A carrier that either holds a successful value of type `T` or is empty (`std::nullopt`). - **`expected`** (representing $T + E$): A carrier that either holds a successful value of type `T` or an error of type `E`. -*(Note: `libfn` provides highly optimized, standards-conforming polyfills of `std::optional` and `std::expected` under the `pfn` namespace for C++20 compilers, while the `fn` namespace extends them.)* + +*(Note: `optional` and `expected` are the `fn` extensions of the standards-conforming `pfn` polyfills; Section 15 covers the two layers.)* ### The infallible (identity) carriers - **`just`**: Always contains a single successful value of type `T`. - **`choice`**: Always contains one of several selected alternatives, representing the complete state space of the computation. -- **`expected>`** (representing $T + 0 \cong T$): Symmetrically to `just`, this carrier can never fail because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. + +- **`expected>`** (representing $T + 0 \cong T$): not a fifth family but a state of `expected` — symmetrically to `just`, this shape can never fail because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. @@ -270,7 +346,11 @@ Because `choice` implies that an alternative is always present, `choice<>` is in > > In fact, attempting to instantiate `just>` will trigger a compile-time static assertion failure inside `just`, explicitly warning the programmer: `"a just over a copack is spelled choice"`. -These carriers impose constraints on their payloads, but reference payloads are broadly supported where sound. For example, `optional` is supported and well-defined. + +These carriers constrain their payloads. `optional` is supported and well-defined; the other carriers hold references only inside a `pack` (Section 15). ### Carriers have control flow; raw data does not @@ -280,10 +360,17 @@ To compose computations, we must wrap these values inside **computation carriers ### Carrier Bridging: Interoperable Pipelines -Because these carriers represent different computational contexts, pipelines often need to transition between them. `libfn` licenses explicit **cross-carrier bridging** via pipeline-scoped operations using `operator|`: + +Because these carriers represent different computational contexts, pipelines often need to transition between them. `libfn` licenses explicit **cross-carrier bridging** via pipeline-scoped operations using `operator|`. -1. **Failure-Path Bridging (`or_else` between Fallible Carriers)**: - Standard fallible carriers can bridge to each other on their error/empty recovery paths via `or_else` (e.g., `expected` to `optional`, or vice versa). This is safe because on the success path, the successful value is preserved and bypasses the recovery callback entirely. The transition only occurs on the handled failure branch, allowing you to gracefully convert a missing value into a concrete error, or decay a detailed error into an empty state: +Standard fallible carriers can bridge to each other on their error/empty recovery paths via `or_else` (e.g., `expected` to `optional`, or vice versa). This is safe because on the success path, the successful value is preserved and bypasses the recovery callback entirely. The transition only occurs on the handled failure branch, allowing you to gracefully convert a missing value into a concrete error, or decay a detailed error into an empty state: ```cpp @@ -298,29 +385,7 @@ auto test_failure_bridge(fn::expected ex, fn::optional opt) - } ``` -1. **Success-Path Bridging (`and_then` from Identity to Fallible)**: - Identity carriers can safely bridge to any fallible carrier via `and_then`. Since an identity carrier (like `just` or `choice`) is statically proven to be infallible, transitioning to `optional` or standard `expected` merely introduces potential failure downstream. No pre-existing failure state is discarded because none can exist upstream: - - -```cpp -auto test_success_bridge() -> void -{ - fn::just j{1}; - - // An identity carrier can bridge to fallible carriers on the success path - auto to_opt = j | fn::and_then([](int i) { return fn::optional{i}; }); - static_assert(std::same_as>); - - auto to_exp = j | fn::and_then([](int i) { return fn::expected{i}; }); - static_assert(std::same_as>); - - // Bridging a multi-alternative choice to fallible optional with heterogeneous success join: - auto choice_to_opt = fn::choice_for{true} - | fn::and_then(fn::overload{[](int) -> fn::optional { return {'a'}; }, - [](bool) -> fn::optional { return {2L}; }}); - static_assert(std::same_as>>); -} -``` +Identity carriers bridge in the other direction, on the success path; Section 10 covers that together with the identity cluster. ## 4. The sum and product payloads: pack and copack @@ -328,11 +393,20 @@ While the computation carriers manage control flow and fallibility, modeling mor ### pack: all fields are present -A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: attempting to nest a `pack` inside another `pack` via `append` flattens them, as a flat pack is canonical. + +A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: a `pack` is not a valid element of a `pack`, so `append`ing one splices its fields into the outer pack rather than nesting it. To explicitly lift values into a `pack` (which is useful when conjoining scalars with other packs or copacks), use `fn::as_pack(...)`. When called without template parameters, `as_pack` is deduction-only and preserves the value category of its arguments: `as_pack(42)` yields `pack`, whereas calling `as_pack(x)` on an lvalue `x` yields `pack` (a reference rather than a copy). -Symmetrically, explicitly specifying the template parameters (e.g., `as_pack(x, d)`) opts out of reference preservation. In this explicit form, arguments are passed by-value (meaning lvalues are copied or decayed), enabling implicit type conversions and coercions at the call boundary. Note that partial template spelling is not supported; all element types must be spelled out explicitly if template parameters are specified. + +Spelling the template parameters instead (e.g., `as_pack(x, d)`) takes deduction out of the picture: each argument is passed by value in the type you named, so lvalues are copied and implicit conversions happen at the call boundary. A reference element becomes something you ask for explicitly — `as_pack(x)` yields `pack`. Note that partial template spelling is not supported; all element types must be spelled out explicitly if template parameters are specified. ```cpp @@ -379,11 +453,23 @@ auto test_pack() -> void ### copack: one exact alternative is present -A `copack` represents a canonical disjoint sum (or coproduct) storing exactly one of its defined alternative types. This is ideal for modeling variant-like structures, such as lexical tokens or parsed configuration keys. + +As a payload, a `copack` models variant-like structures such as lexical tokens or parsed configuration keys. -When you evaluate a `copack` via its member `apply` function, it selects the active alternative stored inside the coproduct and passes it to your callback. Because `copack` is self-flattening, you are guaranteed that there is never a nested `copack` inside. However, any nested tuple-like structures—such as `pack`, `std::tuple`, or `std::array`—are recursively unpacked into their individual constituents during multidispatch. By keeping your shapes normalized as a sum-of-products, `libfn` guarantees that your callbacks always receive clean, terminal domain data directly as function arguments. + +When you evaluate a `copack` via its member `apply` function, it selects the active alternative stored inside the coproduct and passes it to your callback. Because `copack` is self-flattening, you are guaranteed that there is never a nested `copack` inside. However, a selected alternative that is itself tuple-like—a `pack`, `std::tuple`, or `std::array`—is unpacked one level into its immediate constituents, which reach your callback as separate arguments. Because normalized shapes are sums of products, one level is all they need: your callback receives the product's fields directly as function arguments. -To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. + +To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. Unlike `as_pack`, it always decays: a `copack` alternative can never be a reference. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. ```cpp @@ -406,7 +492,14 @@ auto test_copack() -> void } ``` -A fundamental safety guarantee of `copack` is **exhaustive matching**. Any operation that evaluates a `copack` (such as mapping with `transform`, binding with `and_then`, or eliminating with `apply`) eventually delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. The same discipline explains why direct `get` extraction is disallowed for multi-alternative `copack` types: allowing partial extraction would bypass compile-time exhaustiveness guarantees. + +A fundamental safety guarantee of `copack` is **exhaustive matching**. Every operation that evaluates a `copack` — mapping with `transform`, eliminating with `apply` — delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. Direct `get` extraction is disallowed for multi-alternative `copack` types for a related reason: which alternative is active is a run-time fact, so a `get` over several alternatives has no single static result type to return. Extraction has to go through dispatch, and dispatch is exhaustive. ## 5. Mapping values and errors @@ -430,14 +523,27 @@ auto mapping_values_and_errors() -> void Key principles of mapping: -- `transform` stays strictly within the carrier type. + +- `transform` stays within the carrier: the member form never leaves its own carrier family. - Success and error states are rigidly preserved. -- A bare `copack` also has `transform`, allowing mapping across alternatives. +- A bare `copack` has a member `transform`, allowing mapping across alternatives; being data rather than a carrier, it takes no pipeline verb. - Heterogeneous branch results inside `transform_error` form a normalized result `copack`. - Applying an error-side operation like `transform_error` to a carrier that has no error side (like `just` or `choice`) is rejected by the compiler. - If a side is uninhabited (`copack<>`), the transformation is well-formed, but vacuous (i.e., a no-op): - `transform_error` on `expected>` is proven unreachable and a no-op. - - `transform` on `optional>` is proven unreachable and a no-op. + + - The member `transform` on `optional>` is proven unreachable and a no-op. > [!TIP] > @@ -448,24 +554,23 @@ Key principles of mapping: > - **Identity**: $F(id_A) = id_{F(A)}$ > - **Composition**: $F(g \circ f) = F(g) \circ F(f)$ > -> In `libfn`, `transform` implements this morphism mapping ($fmap$). Functorial action on the initial object $0$ (the uninhabited `copack<>`) is vacuous: since there are no morphisms originating from $0$ (except the unique initial morphism), mapping over an empty alternative set is vacuously true. The compiler leverages this by optimizing `transform` on `optional>` into a static no-op. +> +> In `libfn`, `transform` implements this morphism mapping ($fmap$). Functorial action on the initial object $0$ (the uninhabited `copack<>`) is vacuous: since there are no morphisms originating from $0$ (except the unique initial morphism), mapping over an empty alternative set is vacuously true. `libfn` leverages this by making the member `transform` on `optional>` the identity: the callback is never instantiated, let alone called. > ## 6. Product composition with operator& (conjunction) -Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results. - - -```cpp -auto operator_and_composition() -> void -{ - fn::expected> a{}; - fn::expected> b{}; - - auto result = a & b; - - static_assert(std::same_as, fn::copack_for>>); -} -``` + +Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results into a `pack` of the successful values over a `copack` of the exact errors either operand can produce — the conjunction of two `expected`s shown in Section 1. The runtime failure semantics are exact: @@ -474,7 +579,16 @@ The runtime failure semantics are exact: - If both operands already contain errors, the left error is retained. Because C++ operators evaluate eagerly, both operands are already fully constructed before `operator&` runs — this is an error-selection rule, not runtime short-circuiting. - Normal C++ evaluation rules apply: `operator&` does not magically make I/O lazy or parallel. -When composing two `copack`s directly, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s. The variadic entry point into these rules is `fn::conjoin(...)`. Note that bare `scalar & scalar` never enters the algebra by itself: for class types it fails to compile, and for built-in types like `int` it resolves to the built-in bitwise AND. To conjoin scalars, lift them with `fn::conjoin(a, b)` or `fn::as_pack(a) & b` instead. + +When either operand is a `copack`, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s — a `pack` on the other side simply widens each of those `pack`s. The variadic entry point into these data rules is `fn::conjoin(...)`, which folds packs, copacks and scalars; conjoining carriers stays with `operator&`. Note that bare `scalar & scalar` never enters the algebra by itself: for class types it fails to compile, and for built-in types like `int` it resolves to the built-in bitwise AND. To conjoin scalars, lift them with `fn::conjoin(a, b)`, or with `fn::as_pack(a) & b` — the lift has to be the left operand, the side `operator&` dispatches on. ```cpp @@ -499,7 +613,13 @@ auto test_cartesian_distribution() -> void When performing product composition (`operator&`), you can combine fallible carriers (like `expected` or `optional`) with any member of the **identity cluster** (detailed in Section 10): -- **Errors are unaffected**: Because identity cluster operands can never fail, they add no new types or terms to the result's error channel. The error side of the fallible operand is preserved exactly (whether plain or copack-graded). + +- **Errors are unaffected**: Because identity cluster operands can never fail, they add no alternative to the result's error channel. A `just` or `choice` operand leaves the fallible operand's error side exactly as it was, plain or copack-graded. An `expected>` operand instead contributes its uninhabited grade to the error union — no alternative is added, but a plain error `E` is thereafter spelled as the singular `copack`. - **Value bundling**: The value of the identity cluster operand is conjoined with the fallible operand's value channel into a `fn::pack`. - **Unit elision**: `just` and `expected>` act as the product's identity unit and are completely elided from the value product (e.g., `expected & just` stays `expected`). - **Choice distribution**: If a `choice` operand is conjoined with a fallible carrier, the coproduct distributes through the product. This yields a `copack` of `pack`s wrapped back inside the fallible carrier. @@ -541,7 +661,13 @@ auto test_conjunction_with_identity_cluster() -> void > ## 7. Sum composition with operator| (disjunction) -Simultaneous sum composition combines alternative computations. By evaluating `a | b`, you attempt the left computation `a`. If it succeeds, its result is preserved. If it fails, you evaluate the right computation `b` as a fallback. + +Simultaneous sum composition combines alternative computations. By evaluating `a | b`, the leftmost operand holding a value wins: if `a` succeeded its result is preserved, otherwise `b`'s is. As with `operator&`, both operands are fully constructed before the operator runs — this is a value-selection rule, not a lazy fallback. ```cpp @@ -564,7 +690,12 @@ The runtime and compile-time semantics of disjunction are exact: - `void` results enter a genuine sum as `pack<>`. If both operands are `void`, they collapse back to `void`. - **Error-side product**: - Because the overall disjunction only fails if *both* operands fail, the error channel represents the product of both errors. This is recorded positionally inside `fn::pack`. - - If both operands contain graded error sets (`copack`s of errors), the errors distribute through the product: $(El + Er) \times (El') \to (El \times El') + (Er \times El')$. This yields a `copack` of `pack`s, representing all combinations of failure states. + + - If either operand's error side is a graded set (a `copack` of errors), the errors distribute through the product: $(E_1 + E_2) \times F \to (E_1 \times F) + (E_2 \times F)$. When both sides are graded, the distribution is the full Cartesian product. Either way the result is a `copack` of `pack`s, recording every combination of failure states. - **Total Disjunction and the Identity Cluster**: - If at least one operand belongs to the **identity cluster** (detailed in Section 10), the disjunction is guaranteed to never fail at runtime. - The error side gains an uninhabited factor (`copack<>`), which collapses the error channel entirely and prevents the result from failing. @@ -606,7 +737,16 @@ auto test_disjoin() -> void Sequential composition chains dependent operations where the success of one feeds the input of the next. In `libfn`, this is achieved using `and_then` (monadic bind). -A monadic carrier wraps a value. A *Kleisli arrow* is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind. `and_then(f)` produces a storable operation value that can be piped. + +A monadic carrier wraps a value. A *Kleisli arrow* is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind — or, from an infallible input, of the fallible kind it bridges to (Section 10). `and_then(f)` produces a storable operation value that can be piped. + +The graded pipeline of Section 1 is this bind chained; here is the rule on its own: ```cpp @@ -624,7 +764,12 @@ auto sequential_bind() -> void The strict "same-kind" contract defines how types interact: - An `optional` binds to an `optional`. -- A plain `expected` binds to an `expected`, retaining its exact plain error type. + +- A plain `expected` binds to an `expected`, retaining its exact plain error type, or to an `expected>`, which grades it (Section 9). - A copack-graded `expected` can union heterogeneous error sets (as demonstrated above). - A copack-valued input can join heterogeneous successful branch types into a normalized `copack`. - Exact branch convergence preserves the exact type without creating duplicate union states. @@ -657,7 +802,10 @@ static_assert(!fn::same_kind, fn::expected > $$\mu \circ M(\mu) = \mu \circ \mu_M \quad \text{and} \quad \mu \circ M(\eta) = id_M = \mu \circ \eta_M$$ > -> In C++, `and_then` implements the bind operation, while `transform` implements the endofunctor map $M(f)$. These laws are verified statically under constant evaluation in Section 14. +> +> In C++, `and_then` implements the bind operation, while `transform` implements the endofunctor map $M(f)$. Two of these laws are checked statically under constant evaluation in Section 14. > ## 9. Graded expected: exact error sets @@ -690,7 +838,12 @@ Two independent joins occurred during `and_then`: 1. The successful branch values formed the normalized value copack. 2. The existing outer errors (`BadSyntax`, `UnknownKey`) and the new branch errors (`OutOfRange`, `Missing`) formed the normalized error copack. -This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` requires the exact same error type `E` to participate in monadic bind (meaning `expected` and `expected` are **not** `same_kind`), any two graded `expected` types are considered `same_kind`, regardless of how their individual error sets differ: + +This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` admits only the same error type `E`, or its singular lift `copack`, to participate in monadic bind (meaning `expected` and `expected` are **not** `same_kind`), any two graded `expected` types are considered `same_kind`, regardless of how their individual error sets differ: ```cpp @@ -710,7 +863,11 @@ To make composition more user-friendly, `libfn` allows explicit **type promotion This ensures that you can smoothly transition from a simple, un-graded computation to a graded, multi-alternative computation when entering a pipeline step that introduces alternative paths, without needing to manually wrap or lift your starting types. -If you need to perform this promotion explicitly on the carrier itself before entering a composition, `libfn` provides direct, zero-cost member helpers: + +If you need to perform this promotion explicitly on the carrier itself before entering a composition, `libfn` provides direct member helpers: - `.copack_error()` on `expected` explicitly lifts the error, transforming `expected` to `expected>`. - `.copack_value()` on `expected` explicitly lifts the success value, transforming `expected` to `expected, E>`. @@ -736,11 +893,21 @@ auto test_explicit_lifting(fn::expected result, fn::optional> is proven unreachable and a + no-op"), so the omission here was a real gap: expected.hpp:405-415 returns *this for + an empty copack error, leaving the callback "not invoked and not even instantiated" + (or_else.hpp:195-204 routes to that arm). This is also the fact the NOTE in §10 depends on. --> +Recovery via `or_else` behaves symmetrically. It handles input error alternatives and joins any new errors produced by the recovery branches while preserving the already-successful value path. Heterogeneous recovery values require a suitable copack-valued input. Any original error handled by a branch does not automatically remain possible unless a branch explicitly returns it again. With no error alternative to handle — an `expected>` — there is nothing to recover from: the callback is never invoked, and never even instantiated. ### Widening is subeffecting -In accordance with the subeffecting principles of graded monads (Section 1), a narrow error set can be safely widened during composition, but narrowing requires explicit mitigation. Implicit narrowing (without handling the removed errors) is unsafe and rejected by the compiler. However, you can **safely narrow or collapse** an error grade at any point by explicitly handling and mapping the errors using `transform_error`. Because `transform_error` on a graded `expected` forces exhaustive matching over all possible alternatives, you can map multiple diverse error types into a single common error type (or a narrower `copack`), safely reducing the static error grade of your pipeline. +In accordance with the subeffecting principles of graded monads (Section 1), a narrow error set can be safely widened during composition, but narrowing requires explicit mitigation. Implicit narrowing (without handling the removed errors) is unsafe and rejected by the compiler. +However, you can **safely narrow or collapse** an error grade at any point by explicitly handling and mapping the errors using `transform_error`. Because `transform_error` on a graded `expected` forces exhaustive matching over all possible alternatives, you can map multiple diverse error types into one common error type — the grade collapses to the singular `copack` of it — or into a narrower `copack`, safely reducing the static error grade of your pipeline. The bottom error grade is `copack<>`: @@ -761,7 +928,10 @@ In practice, `expected>` acts as **the graded gateway** to start > > Having established the error pomonoid $(\mathcal{E}, \cup, \emptyset, \subseteq)$ in Section 1, we can formally define `libfn`'s graded `expected` as a **lax monoidal functor** ($G : \mathcal{E} \to [\mathcal{C}, \mathcal{C}]$) from the pomonoid category $\mathcal{E}$ to the endofunctor category on C++ types (following Orchard, Wadler, and Eades, *Unifying graded and parameterised monads*). > -> Under this formulation, the type `expected>` represents the **monadic unit** ($\eta$) of the graded structure: +> +> Under this formulation, the type `expected>` is what the graded structure's **monadic unit** ($\eta$) delivers at the unit object: > > $$\eta_A : A \to G_I(A) \cong \text{expected}\langle A, \text{copack}\langle\rangle\rangle$$ > @@ -779,9 +949,15 @@ Consider this cross-carrier table: | `choice` | **Ts...** (A coproduct of values) | | `expected>` | **T + 0** ≅ **T** (A value and an uninhabited error) | -These three computation carriers have canonically isomorphic state shapes—they all guarantee a successful value of some type. + +Each of these carriers is canonically isomorphic to its own payload: none of them adds a failure or an empty state, so a successful value is always present. -Because they are equivalent, `libfn` provides a licensed pipeline operation that allows binding across these boundaries: +Because none of them can hide an inhabited failure state, `libfn` provides a licensed pipeline operation that allows binding across these boundaries: ```cpp @@ -796,21 +972,67 @@ auto test_identity_cross() -> void } ``` -The bind operation adopts the carrier family of the provided callback. However, the member `and_then` remains strict to its own carrier family. Only the pipeline `operator|` acts as the licensed cross-carrier operation. + +The bind operation adopts the carrier family of the provided callback. However, the member `and_then` remains strict to its own carrier family. The pipeline-scoped functors are the licensed cross-carrier place; the members stay uncoupled from one another. Furthermore, fallible types like `expected` (with inhabited error states) and `optional` cannot indiscriminately switch to other carriers, because doing so would risk silently discarding an inhabited state. ### Success-Path Bridging -As detailed and illustrated in Section 3, while standard fallible carriers cannot change families on the success path, **identity carriers are licensed to bridge to any fallible carrier** via the pipeline `and_then`. Because identity carriers (like `just`, `choice`, or `expected>`) are statically proven infallible, transitioning to a fallible carrier simply introduces potential failure downstream without discarding any pre-existing error or empty state. + +While standard fallible carriers cannot change families on the success path (Section 3), **identity carriers are licensed to bridge to any fallible carrier** via the pipeline `and_then`. Because an identity carrier is statically proven infallible, transitioning to `optional` or a standard `expected` merely introduces potential failure downstream. No pre-existing failure state is discarded, because none can exist upstream: + + +```cpp +auto test_success_bridge() -> void +{ + fn::just j{1}; + + // An identity carrier can bridge to fallible carriers on the success path + auto to_opt = j | fn::and_then([](int i) { return fn::optional{i}; }); + static_assert(std::same_as>); + + auto to_exp = j | fn::and_then([](int i) { return fn::expected{i}; }); + static_assert(std::same_as>); + + // Bridging a multi-alternative choice to fallible optional with heterogeneous success join: + auto choice_to_opt = fn::choice_for{true} + | fn::and_then(fn::overload{[](int) -> fn::optional { return {'a'}; }, + [](bool) -> fn::optional { return {2L}; }}); + static_assert(std::same_as>>); +} +``` + +All three cluster members bridge this way: `just`, `choice`, and `expected>`. Monadic operations behave naturally around this identity cluster: -- **Success mapping (`transform`)**: Remains meaningful and stays inside the nominal carrier family when using member functions. However, when using the pipeline `operator|`, returning a `copack` from `fn::transform` on an identity carrier automatically promotes the result to `choice` (as detailed in Section 11). + +- **Success mapping (`transform`)**: Remains meaningful, and the member always stays inside its own carrier family. The pipeline `fn::transform` adds one licensed crossing: a `copack` returned from a callable mapped over a `just` is promoted to the `choice` over the same alternatives (as detailed in Section 11). - **Sequential binding (`and_then`)**: Allows cross-carrier transitions *within* the identity cluster (e.g., `just` to `expected>`) when using pipeline-scoped `fn::and_then`. -- **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable (to allow generic code on `expected` to compile) +- **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable (to allow generic code on `expected` to compile). - **Short-circuiting (`fail`, `filter`)**: Strictly rejected for all identity cluster carriers, because no failure state (an inhabited error or empty state) can possibly be constructed from a never-failing identity context. -- **Elimination fallbacks (`value_or`)**: Strictly rejected on `just` and `choice` since they can never fail, rendering any fallback redundant and dead. On `expected>`, `value_or` is vacuously well-formed, but the fallback branch is optimized away as unreachable (to allow generic code on `expected` to compile without errors) + +- **Elimination fallbacks (`value_or`)**: Strictly rejected on `just` and `choice` since they can never fail, rendering any fallback redundant and dead. On `expected>`, `value_or` stays well-formed so that generic code on `expected` compiles: the fallback must still be a valid initializer for `T`, but its branch is statically dead. - **Neutral observation (`inspect`, `discard`)**: Fully supported and behave normally. > [!NOTE] @@ -874,8 +1096,15 @@ Similarly, a pipeline-scoped `fn::and_then` on a `just` is permitted to return a Inside its own carrier domain, `choice` behaves differently from a bare `copack` in how it maps and binds: -- A `copack` is plain data. -- A `choice` is a never-failing outer computation over those selected alternatives. Every alternative must be handled. + +- A `copack` is plain data, and it is self-flattening: a `copack` returned from a branch dissolves into the result. +- A `choice` is a never-failing outer computation over those alternatives, and it is an atom: a `choice` returned from a branch survives as one alternative unless `and_then` explicitly joins it away. Consider a scenario where different branches of a switch return different `choice` types: @@ -900,7 +1129,12 @@ auto test_choice_mapping() -> void } ``` -Bare-value callbacks are rejected by `choice`'s `and_then`. + +A callback returning a bare value belongs to `transform`, not `and_then`: `choice`'s `and_then` rejects it, and does so with a named diagnostic rather than by silently dropping out of the overload set. > [!TIP] > @@ -913,7 +1147,11 @@ Bare-value callbacks are rejected by `choice`'s `and_then`. > > 2. **`choice` is the monad (the "structural suspend button")**: > To restore monad laws, the monadic carrier `choice` wraps the sum in an "identity layer" to preserve structural depth: `choice>` $\ne$ `choice`. This "structural suspend button" holds eager flattening in check. -> Thus, `choice` acts as a lawful monad under the parameterized endofunctor $M(A) = A + \bigoplus_{j} T_j$: +> +> Thus, `choice` acts as a lawful monad under the identity endofunctor $M(A) = A$, taken over coproduct objects $A = \bigoplus_{j} T_j$: > - **Unit / return** ($\eta_A : A \to M(A)$): Canonical injection into the coproduct. > - **Join / flatten** ($\mu_A : M(M(A)) \to M(A)$): Strips one layer of the `choice` wrapper, allowing the underlying sum semantics to deduplicate variants (the codiagonal fold $[id, id]$, executed statically via `choice_for`). > - **Bind**: Composes callbacks by mapping and explicitly flattening via `join`. This explicit step grants control over *when* flattening occurs, turning a loose collection of types into a rigorous Monad. @@ -926,7 +1164,10 @@ Once your computation shapes are fully derived, you must eliminate the structure It is vital to distinguish `transform` from `apply`: - `transform` stays *inside* the carrier or copack, producing a new carried type. -- `apply` *eliminates* the structure entirely, requiring all branches to converge on one deduced result type. + +- `apply` *eliminates* the structure entirely: the result type is deduced from the branches, which must then all yield that one same type. - `apply_r` permits branch results acceptable as the specific type `R`. > [!NOTE] @@ -970,11 +1211,16 @@ Exhaustiveness is statically constrained. If you omit a handler for a possible t ### Type-tagged elimination -Because multiple structures can share the same unpacking call shape (e.g., `pack` and `std::tuple` both call `f(a, b)`), untagged `apply` can sometimes erase the structural context of the state. To preserve this context and prevent permissive C++ implicit conversions from accidentally conflating different states, `libfn` provides the **`apply_type`** (and `apply_type_r`) member functions. + +Because storage shape and call shape are distinct, untagged `apply` can sometimes erase the structural context of the state. To preserve this context and prevent permissive C++ implicit conversions from accidentally conflating different states, `libfn` provides the **`apply_type`** (and `apply_type_r`) member functions. When you eliminate a carrier using `apply_type`, the active handler receives an explicit C++ state tag or constructor tag as its first argument, followed by the unpacked payload: -- On `expected`, the success arm receives `std::in_place` followed by the success value, while the error arm receives `fn::unexpect` followed by the error. + +- On `expected`, the success arm receives `std::in_place` followed by the success value — `std::in_place` alone when the value type is `void` — while the error arm receives `fn::unexpect` followed by the error. - On `optional`, the success arm receives `std::in_place` followed by the value, while the empty arm receives `std::nullopt`. - On `copack` and `choice`, the active alternative arm receives `std::in_place_type` followed by the payload. - On `just`, the arm receives `std::in_place_type` followed by the value. Symmetrically, `just`'s arm receives `std::in_place_type` alone — representing a nullary unit payload (never an empty or uninitialized state). @@ -998,22 +1244,39 @@ This is a concise reference for `libfn`'s operations, organized by channel and e - `transform`: Maps the successful value. Stays inside the carrier. - `and_then`: Sequences success-path computations. The mechanism for introducing new errors into a graded expected. -- `filter`: Enters a short-circuit state if a predicate fails. Does not widen error grades. + +- `filter`: Enters a short-circuit state if a predicate fails. - `inspect`: Observes the successful value transparently. -- `fail`: Intercepts success and forces a transition to a failure state. Does not widen error grades. +- `fail`: Intercepts success and forces a transition to a failure state. **Error/Empty Channel** -- `transform_error`: Maps the error value. Stays inside the carrier. + +- `transform_error`: Maps the error value. Stays inside the carrier, and is the one operation that can narrow a graded error set (Section 9). - `or_else`: Sequences computations based on errors. Joins recovery values. - `recover`: Intercepts failure and forces a transition back to a success state. - `inspect_error`: Observes the error value transparently. -- `value_or`: Eliminates the carrier by supplying a fallback value on failure. +- `value_or`: Supplies a fallback for the failure state. The member `.value_or(x)` eliminates the carrier and yields the value; the pipeline `fn::value_or(x)` keeps the carrier, returning it engaged with either its own value or the fallback. **Neutral** - `discard`: Unconditionally evaluates the carrier, discards the result, and returns `void`. This is used to signal to the compiler that the return value is deliberately ignored. + +**Elimination** + +- `apply`: Routes the stored state to an overload set, leaving the algebra with an ordinary C++ value. +- `apply_type`: The same elimination, keyed by an explicit state tag. + **Composition & Combination** - `operator&` (conjunction): Combines independent computations (values into a `pack`, errors as a union). @@ -1027,13 +1290,24 @@ To reason about how these operations affect the type algebra of your computation - **`fail` and `recover` are dual symmetries**: `fail` intercepts a success-path value and forces a transition to the failure state ($Success \implies Failure$). `recover` intercepts a failure-path error and forces a transition back to the success state ($Failure \implies Success$). Neither operation widens the error set of a graded carrier. - **Graded `and_then`** is the primary mechanism for introducing a _new_ error type (widening the error grade) into your pipeline. -- **`filter` and `fail`** merely enter an _existing_ short-circuit state. They do not widen the error grade (the type must already be capable of holding the failure state). -- **Error-side monadic operations** (like `transform_error`, `or_else`, `recover`, and `inspect_error`) are only well-formed if the carrier has an appropriate error or empty side (and are rejected on identity carriers like `just` or `choice`). +- **`filter` and `fail`** merely enter an _existing_ short-circuit state: the carrier must already be capable of holding the failure state. + +- **Error-side monadic operations** (like `transform_error`, `or_else`, `recover`, and `inspect_error`) require a carrier with an error or empty side. They are rejected on `just` and `choice`, and stay vacuously well-formed on `expected>`, whose error side exists but is uninhabited (Section 10). ## 14. Laws as C++ equalities -The algebraic laws governing `libfn` shapes are verified by the compiler where structural capabilities permit. For instance, you can observe functor identity and monad left identity in `constexpr` contexts: + +Where the carried types compare equal in a constant expression, the laws are checked by the compiler itself. Functor identity and monad left identity are machine-checked below; the remaining laws hold structurally, by construction of the derived types: ```cpp @@ -1053,9 +1327,15 @@ constexpr auto test_laws() -> void ``` Other properties hold structurally: -- **Functor composition**: `transform(f) | transform(g)` equals `transform(g(f(x)))`. + +- **Functor composition**: `m | transform(f) | transform(g)` equals `m | transform([](auto v) { return g(f(v)); })`. - **Monad right identity**: `m | and_then(pure)` equals `m`. -- **Monad associativity**: `(m | and_then(f)) | and_then(g)` equals `m | and_then(\x -> f(x) | and_then(g))`. For graded expected, both sides of the associativity derive the exact same normalized union grade. +- **Monad associativity**: `(m | and_then(f)) | and_then(g)` equals `m | and_then([](auto v) { return f(v) | and_then(g); })`. For graded expected, both sides of the associativity derive the exact same normalized union grade. - **Product associativity**: Holds after canonical `pack` normalization. - **Coproduct set semantics**: Union associativity, commutativity, and idempotence apply. - **Coherent widening**: Upcasting an error through intermediate supersets yields the same final type as upcasting directly to the broadest superset. @@ -1068,13 +1348,24 @@ To make the algebraic model reliable in everyday C++, `libfn` uses extensive com ### Constraints and exhaustiveness -Public concepts and `requires` clauses enforce correctness before instantiation. Operations are protected by applicability concepts (negative probes) that proactively reject impossible calls. This underpins the compile-time exhaustiveness guarantees of `apply` and monadic operations established in Sections 3 and 11, catching unhandled alternatives at the boundary of instantiation rather than deep inside template machinery. + +Public concepts and `requires` clauses enforce correctness before instantiation. Operations are protected by public applicability concepts (`fn::applicable_transform`, `fn::applicable_and_then`, …) that answer *false* for an impossible call instead of erroring deep inside template machinery. This underpins the compile-time exhaustiveness guarantees of `apply` and monadic operations established in Sections 4 and 12, catching unhandled alternatives at the boundary of instantiation. ### C++ value properties `libfn` thoroughly respects C++ value mechanics: - Core operations are fully `constexpr`. -- Types are structural if their elements permit (allowing them as non-type template parameters). + +- The algebra's own types — `pack`, `copack`, `just` and `choice` — are structural when their elements are, so a `constexpr` value of one can be used as a template parameter. - `noexcept` is conditionally computed based on the operations provided. - Value categories (lvalue/rvalue) propagate strictly to callbacks, avoiding unnecessary copies. - Immovable and move-only payloads are fully supported in place. @@ -1082,9 +1373,18 @@ Public concepts and `requires` clauses enforce correctness before instantiation. > [!NOTE] > -> ### Note — Reference Restrictions on Carriers +> ### Note — Reference Restrictions > -> To preserve the C++ standard's structural constraints, raw reference payloads are strictly disallowed as primary template parameters on carriers like `expected`, `copack`, `choice`, or `just`. If you want to propagate references inside these carriers, you must wrap them inside a `pack` (e.g. `expected, E>`). +> +> Raw reference payloads are disallowed on the carriers `expected`, `just` and `choice`, and as `copack` alternatives. `expected` stores its payload in a union, and C++ forbids a union member of reference type; the algebra's own types reject references so that dispatch granularity stays uniform per payload. `optional` is the deliberate exception — the standard specifies it, and `libfn` polyfills it. If you want to propagate references inside the other carriers, wrap them in a `pack` (e.g. `expected, E>`). ```cpp @@ -1106,9 +1406,17 @@ auto test_references() -> void The library is divided into layers: -- `pfn` (Polyfill fn) is the standards-facing layer. It provides polyfills of `std::optional` and `std::expected`, conforming to standard C++26 (and later) shapes. + +- `pfn` (Polyfill fn) is the standards-facing layer. It provides `std::optional` and `std::expected` in their C++26 shape — monadic member functions, `optional`, range support — plus smaller utilities such as `std::invoke_r` and `std::unreachable`, all available to a C++20 compiler. - `fn` is the strict extension layer. It introduces the `pack`/`copack` algebra, multidispatch, graded errors, `choice`, `just`, the pipeline verbs, and the composition operators `&` and `|`. +Every `fn` type with a `pfn` counterpart is a strict superset of it: switching a valid program from `pfn` to `fn` changes neither compilation nor behaviour. + ## Functional terminology For readers with a background in functional languages (like Haskell or OCaml), this table translates standard terminology to `libfn`'s C++ vocabulary: @@ -1117,10 +1425,16 @@ For readers with a background in functional languages (like Haskell or OCaml), t | --------------- | ------------------ | | `fmap` / `map` | `transform` / `transform_error` | | `bind` / `>>=` | `and_then` | -| `pure` / `return` | Constructor / `just` / Factory functions | + +| `pure` / `return` | `just{v}` / `expected>{v}` — a carrier constructor | +| Lift / inject | `fn::as_pack` / `fn::as_copack` | | Kleisli arrow | The callable passed to `and_then` | | Product type | `pack` / `std::tuple` | -| Coproduct / Sum | `copack` / `choice` | +| Coproduct / Sum | `copack` (the sum itself) / `choice` (the never-failing carrier over a sum) | | Subeffecting | Widening an error grade / subset inclusion | ## Further reading diff --git a/examples/type_algebra/main.cpp b/examples/type_algebra/main.cpp index 96e3c8cb..8ad146dd 100644 --- a/examples/type_algebra/main.cpp +++ b/examples/type_algebra/main.cpp @@ -19,6 +19,7 @@ #include #include +// sync-example-types-def struct Error {}; struct OtherError {}; @@ -27,7 +28,6 @@ struct B {}; struct C {}; struct D {}; -// sync-example-types-def struct UserId {}; struct User {}; struct FilePath {}; @@ -189,18 +189,6 @@ auto mapping_values_and_errors() -> void } // sync-example-mapping-values-and-errors -// sync-example-operator-and-composition -auto operator_and_composition() -> void -{ - fn::expected> a{}; - fn::expected> b{}; - - auto result = a & b; - - static_assert(std::same_as, fn::copack_for>>); -} -// sync-example-operator-and-composition - // sync-example-cartesian-distribution auto test_cartesian_distribution() -> void { @@ -462,7 +450,6 @@ int main() test_pack(); test_copack(); mapping_values_and_errors(); - operator_and_composition(); test_cartesian_distribution(); test_conjunction_with_identity_cluster(); operator_or_composition(); From 95211400e6bd4fd13ba84e6b0dceaac46faa11ba Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 13:43:14 +0100 Subject: [PATCH 16/51] Revise TYPE_ALGEBRA prose and rework the compiled examples Reformat examples/type_algebra at 100 columns (new .clang-format), turn example fixtures into parameters so the quoted fences show their types, and strengthen the assertions - including new witnesses for as_pack forcing a reference element and for a reference-bearing pack stored in a copack. The disjoin example now shows the binary error-side product rather than unary forwarding. Prose: copack grades the value side as well as the error side; prefer copack_for/choice_for to naming copack/choice directly; member verbs cannot bridge carriers; sort-key collisions are a limitation of builds without std::type_order. Correct .value() on a fallible carrier - it is partial and throws, it does not discard the error channel. Assisted-by: Claude:claude-opus-5 --- TYPE_ALGEBRA.md | 292 ++++++++++++++-------------- examples/type_algebra/.clang-format | 3 + examples/type_algebra/main.cpp | 175 +++++++++-------- 3 files changed, 241 insertions(+), 229 deletions(-) create mode 100644 examples/type_algebra/.clang-format diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 1c1edb4c..eae3e40c 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -13,12 +13,12 @@ apply/apply_r/append (include/fn/pack.hpp:175-299). --> The library operates on two payload types and four computation carriers: -- `pack`: Product type containing all fields. -- `copack`: Canonical coproduct containing exactly one alternative. +- `pack`: Product type containing all fields; a tuple-like data structure. +- `copack`: Canonical coproduct containing exactly one alternative; a variant-like disjoint set of types. - `optional`: Computation yielding a value or empty. - `expected`: Computation yielding success or error. - `choice`: Never-failing computation holding one of several alternatives. -- `just`: Identity computation always yielding a single value. +- `just`: Never-failing computation yielding a single value or a `void`. -`operator|` carries two meanings, told apart by its right operand: a pipeline functor on the right feeds the carrier into that operation, while another carrier on the right is disjunction (Section 7). +The `operator|` carries two meanings, told apart by its right operand: a pipeline functor on the right feeds the carrier into that operation, while another carrier on the right is disjunction (explained in Section 7). -Freestanding `fn::apply(f, args...)` is the general multidispatch entry point: it accepts any mix of scalars, tuple-like structures, `pack`s and `copack`s, unpacking products and dispatching over alternatives in a single call. The `std::apply`-shaped two-argument utility is `pfn::apply`. +Freestanding `fn::apply(f, args...)` is the general multidispatch entry point: it accepts any mix of scalars, tuple-like structures, `pack`s and `copack`s, unpacking products and dispatching over alternatives in a single call. Do not confuse with `pfn::apply`, which is a polyfill for the C++26 `std::apply`, meant for C++20 compilers. The `fn::apply` is an extension on top of `pfn::apply`. In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected`, `pack`) when referring to both forms or core vocabulary types generally. @@ -61,16 +61,10 @@ In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected` Although different types can behave identically during application, they remain strictly distinct in memory. For example, `pack`, `std::tuple`, and `std::pair` all unpack into the same call shape `f(a, b)` during `apply`, but they are separate C++ types with distinct layouts. Application does not silently convert or unify types on the storage side. To illustrate these concepts, the examples in this document use a reusable set of value and error types: + ```cpp -struct Error {}; -struct OtherError {}; - -struct A {}; -struct B {}; -struct C {}; -struct D {}; - struct UserId {}; struct User {}; struct FilePath {}; @@ -105,7 +99,8 @@ auto graded_pipeline(std::string_view sv) -> void // The exact derived error union is recorded in the type: static_assert( - std::same_as>>); + std::same_as>>); } ``` @@ -115,15 +110,17 @@ The resulting `expected` statically records that the pipeline yields a `User` on Standard monads are rigid: an `expected` requires every step in a pipeline to return the identical error type `E`. This forces you to define a monolithic global error union upfront. -A **graded monad** relaxes this restriction. Each operation is indexed by a "grade"—a set representing its specific possible errors (its "effects"). As you chain operations, the compiler automatically unions these grades. - - -The resulting error type is **graded**: it expands (or narrows during recovery) to match the *exact* subset of errors possible in the compiled path, providing strict static effect tracking (subeffecting) with zero boilerplate. Grading is opt-in: a `copack` on the error side is what enrols an `expected` in this union arithmetic; a plain `expected` keeps the rigid single-error contract. +The resulting error type is **graded**: it expands (or narrows during recovery) to match the *exact* subset of errors possible in the compiled path, providing strict static effect tracking (subeffecting) with zero boilerplate. + +You may also use `copack` on a value side of most carriers (except for `just>`, which must be spelled `choice`). Grading is opt-in: a `copack` on the error side enrols an `expected` in this union arithmetic; a plain `expected` keeps the rigid single-error contract. Similarly, a `copack` on value side enrolls an `expected` or `optional` into union arithmetics on values. > [!TIP] > @@ -138,29 +135,28 @@ The resulting error type is **graded**: it expands (or narrows during recovery) > - **Identity ($I$)**: The empty set ($\emptyset$), representing the zero-error/never-failing state. > - **Partial order ($\le$)**: Subset relation ($\subseteq$), which licenses effect approximation (subeffecting / widening). > -> For a standard monad $M$, the binding operation maps $M\langle A\rangle \to (A \to M\langle B\rangle) \to M\langle B\rangle$. In `libfn`'s graded monad, bind accumulates effects across the pomonoid: +> For a standard monad $M$, the binding operation maps $M\langle A\rangle \to (A \to M\langle B\rangle) \to M\langle B\rangle$. In `libfn`'s graded monad, *bind* accumulates effects across the pomonoid: > > $$bind : M_E\langle A\rangle \to (A \to M_F\langle B\rangle) \to M_{E \cup F}\langle B\rangle$$ > > This formulation enables strict static effect tracking. A lax monoidal functor maps this pomonoid $\mathcal{E}$ into the endofunctor category $[\mathcal{C}, \mathcal{C}]$, formalizing how C++ type derivations trace exact computational side effects. -Simultaneous product composition achieves the same precision for both values and errors. Using `operator&`, you can evaluate independent computations and bundle their results: +Simultaneous product composition achieves the same type precision for both values and errors. Using `operator&` (i.e. conjunction, explained in section 6), you can evaluate independent computations and bundle their results into a single carrier: ```cpp -auto product_composition() -> void +auto product_composition(fn::expected> id, + fn::expected> user) -> void { - fn::expected> id{}; - fn::expected> user{}; - auto bundled = id & user; static_assert( - std::same_as, fn::copack_for>>); + std::same_as, fn::copack_for>>); } ``` -The result contains a `pack` (a flat, tuple-like product) of the successful values, and a `copack` (a sorted, variant-like disjoint sum) of the exact possible errors. +The result contains a `pack` of the successful values, and a `copack` of the exact possible errors. ### The Two Cooperating Mechanisms @@ -175,7 +171,7 @@ Behind these precise compiled types are two independent mechanisms that cooperat always relies on ordinary C++ overload resolution") stated eleven sections early. --> To route multiple alternative paths inside `apply`, the library provides `fn::overload`, which fuses unrelated lambdas into a single overload set. -These derived types are the actual explanation of the library's design, not an internal template-metaprogramming implementation detail. Understanding the precise algebraic rules of this type algebra and the mechanics of application is key to mastering the library. +These derived types are the actual explanation of the library's design, not an internal template-metaprogramming implementation detail. Understanding the precise algebraic rules of this type algebra and the mechanics of application is key to mastering the `libfn` library. ## 2. Types as an algebra: zero, unit, alternatives, and products @@ -204,8 +200,8 @@ The symbol ≅ indicates an equivalent state shape (an information-level corresp In `libfn`'s algebra, zero and unit are strictly separated: -- `copack<>` is uninhabited. You cannot construct it. Algebraically, it is `0`. -- `pack<>` is the one nullary product value. You can construct it via `pack<>{}`. Algebraically, it is `1`. +- `copack<>` is uninhabited: you cannot construct it. Algebraically, it is `0`. +- `pack<>` is the one nullary product value. You can construct it via `pack<>{}` or `fn::as_pack()`. Algebraically, it is `1`. Because `pack<>` exists, applying a callable to it invokes a nullary function. Because `copack<>` is uninhabited, providing a callback over `copack<>` is statically proven to be unreachable code (dead code). @@ -252,12 +248,15 @@ auto test_copack_set_semantics() -> void > "the orders may differ, which is why the mode is a distinct ABI namespace" - so claiming one > emulates the other inverts the reason the C++26 twin exists. "Lexicographical" goes with it: > the sortkey order is not lexicographic in all modes. --> -> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict total order over types (derived from the compiler's own spelling of each type; a build targeting C++26 uses `std::type_order` instead, which is why the two orders form separate ABIs). If you attempt to instantiate it manually with out-of-order parameters (such as `copack` when `A` precedes `B` in that order) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** +> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict total order over types. The order is derived from the compiler's own spelling of each type; a build targeting C++26 with `LIBFN_CXX26` set will use `std::type_order` to derive the order of types, while a build for older compilers will use a limited emulation implemented in `libfn` (the two orders may be different and form separate ABIs). If you attempt to instantiate `copack` manually with out-of-order parameters (such as `copack` when `A` precedes `B` in that order) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** +> > - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and canonical sorting automatically, and resolving directly to the validated canonical `copack` type. > > To the C++ programmer, they can often be treated as equivalent in APIs because `copack_for` (as a type alias) always resolves directly to `copack`. However, in prose and code, `copack` represents the normalized *state shape*, while `copack_for` represents the *construction utility*. When writing types out manually, `copack` requires the types to already be in strict canonical order, whereas `copack_for` handles arbitrary, out-of-order, or duplicated lists. > > Similarly, `choice`—which is the never-failing identity carrier over a `copack`—utilizes the `choice_for` type alias utility to automatically flatten, deduplicate, and sort its alternative types at compile time. Just like `copack`, `choice` requires its template parameters to be in strict canonical order, whereas `choice_for` accepts arbitrary, out-of-order, or duplicated lists. +> +> **Best practice**: a project will typically **not** name `copack` / `choice` directly, instead it will use `copack_for` / `choice_for` to ensure that the order of types is not tied to a particular compiler / build options. The laws governing `copack` are: @@ -273,7 +272,7 @@ The laws governing `copack` are: silently merged: detail/meta.hpp:189-196 hard-errors with "distinct types must not share a sort key". So the warning "distinct types must never be silently lost" described a hazard the library already forecloses. --> -Idempotence means a set cannot carry positional meaning: `copack_for` is one alternative, `copack`, not two. Types combined into a `copack` should therefore be strongly typed tag structs or distinct domain objects, never generic primitives whose meaning depends on where they sit. Two *distinct* types that the canonical ordering cannot tell apart are a different matter: the library rejects them outright rather than merging them, so nothing is ever silently lost. +Idempotence means a set cannot carry positional meaning: `copack_for` is one alternative, `copack`, not two. Types combined into a `copack` should therefore be strongly typed tag structs or distinct domain objects, never generic primitives whose meaning depends on where they sit. Two *distinct* types that the ordering of types in `copack` cannot tell apart are a different matter: the library rejects them outright rather than merging them, so nothing is ever silently lost. This limitation in particular affects the builds without access to C++26 `std::type_order`, because the limited emulation of type ordering implemented in `libfn` does not have access to full type information. ### The algebra is strictly opt-in @@ -321,6 +320,7 @@ To model computation and manage control flow (success, failure, alternatives, an - **`optional`** (representing $T + 1$): A carrier that either holds a successful value of type `T` or is empty (`std::nullopt`). - **`expected`** (representing $T + E$): A carrier that either holds a successful value of type `T` or an error of type `E`. + - As explained below, `expected` can be also infallible, if its error side is a `copack<>`. @@ -330,13 +330,15 @@ To model computation and manage control flow (success, failure, alternatives, an - **`just`**: Always contains a single successful value of type `T`. - **`choice`**: Always contains one of several selected alternatives, representing the complete state space of the computation. + +Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. + -- **`expected>`** (representing $T + 0 \cong T$): not a fifth family but a state of `expected` — symmetrically to `just`, this shape can never fail because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. -Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. +Additionally, among the infallible types is also **`expected>`** (representing $T + 0 \cong T$). It is a state of `expected` which can never fail, because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. It is **not** a separate computation carrier from `expected`. > [!NOTE] > @@ -370,7 +372,7 @@ To compose computations, we must wrap these values inside **computation carriers between them terminated the first list. --> Because these carriers represent different computational contexts, pipelines often need to transition between them. `libfn` licenses explicit **cross-carrier bridging** via pipeline-scoped operations using `operator|`. -Standard fallible carriers can bridge to each other on their error/empty recovery paths via `or_else` (e.g., `expected` to `optional`, or vice versa). This is safe because on the success path, the successful value is preserved and bypasses the recovery callback entirely. The transition only occurs on the handled failure branch, allowing you to gracefully convert a missing value into a concrete error, or decay a detailed error into an empty state: +Standard fallible carriers can bridge to each other on their error/empty recovery paths via pipeline functor `fn::or_else` (e.g., `expected` to `optional`, or vice versa). This is safe because on the success path, the successful value is preserved and bypasses the recovery callback entirely. The transition only occurs on the handled failure branch, allowing you to gracefully convert a missing value into a concrete error, or decay a detailed error into an empty state: ```cpp @@ -385,6 +387,8 @@ auto test_failure_bridge(fn::expected ex, fn::optional opt) - } ``` +The member function `.or_else` (or `.and_then`, see sections 8 and 10) cannot be used for cross-carrier bridging, as such operation would require coupling between different carriers. Since pipeline functors are a layer above carriers, they can provide such operation without undue coupling. + Identity carriers bridge in the other direction, on the success path; Section 10 covers that together with the identity cluster. ## 4. The sum and product payloads: pack and copack @@ -397,7 +401,7 @@ While the computation carriers manage control flow and fallibility, modeling mor pack element outright (detail/pack_impl.hpp:66), so fn::pack> is ill-formed and never a legal-but-non-canonical spelling. append splices precisely because nesting was never representable. --> -A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting `get`, structured bindings, and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: a `pack` is not a valid element of a `pack`, so `append`ing one splices its fields into the outer pack rather than nesting it. +A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting standard tuple protocol (`get`, `tuple_size`, `tuple_element`, structured bindings), and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: a `pack` is not a valid element of a `pack`, so `append`-ing one splices its fields into the outer pack rather than nesting it. To explicitly lift values into a `pack` (which is useful when conjoining scalars with other packs or copacks), use `fn::as_pack(...)`. When called without template parameters, `as_pack` is deduction-only and preserves the value category of its arguments: `as_pack(42)` yields `pack`, whereas calling `as_pack(x)` on an lvalue `x` yields `pack` (a reference rather than a copy). @@ -406,21 +410,18 @@ To explicitly lift values into a `pack` (which is useful when conjoining scalars reference element: as_pack(k) is pack (tests/fn/pack.cpp:248,272,276). "Opts out of reference preservation" told the reader the opposite. "Symmetrically" was also the wrong connector: the two forms contrast rather than mirror. --> -Spelling the template parameters instead (e.g., `as_pack(x, d)`) takes deduction out of the picture: each argument is passed by value in the type you named, so lvalues are copied and implicit conversions happen at the call boundary. A reference element becomes something you ask for explicitly — `as_pack(x)` yields `pack`. Note that partial template spelling is not supported; all element types must be spelled out explicitly if template parameters are specified. +Spelling the template parameters instead (e.g., `as_pack(x, d)`) takes deduction out of the picture: each argument is passed as the type you named, enabling implicit conversions to happen at the call boundary. A reference element becomes something you ask for explicitly — `as_pack(x)` yields `pack`. Note that partial template spelling is not supported; all element types must be spelled out explicitly if template parameters are specified. ```cpp -auto test_pack() -> void +auto test_pack(int x = 12, double d = 3.14) -> void { - fn::pack p{UserId{}, User{}}; // CTAD - - auto [id, user] = p; // Structured bindings work naturally - (void)id; - (void)user; + fn::pack p{UserId{}, User{}}; // CTAD + [[maybe_unused]] auto [id, user] = p; // Structured bindings work naturally // Ordered, non-deduplicated fields using P = fn::pack; - (void)sizeof(P); + static_assert(std::tuple_size_v

== 3); // Found via ADL (like std::get) using std::get; @@ -433,19 +434,20 @@ auto test_pack() -> void static_assert(std::same_as>); // Explicitly lifting a single scalar value into a pack: - int x = 42; auto lifted_lvalue = fn::as_pack(x); static_assert(std::same_as>); auto lifted_rvalue = fn::as_pack(42); static_assert(std::same_as>); - // Spelling the element type explicitly opts out of reference preservation - an owned copy: + // Spelling the element type explicitly can be used to opt out of reference preservation: auto copied = fn::as_pack(x); static_assert(std::same_as>); + // ... or to force a specific reference type (subject to parameter binding rules): + auto referenced = fn::as_pack(x); + static_assert(std::same_as>); // The explicit form also coerces - the argument converts at the call boundary: - double d = 3.14; auto coerced = fn::as_pack(x, d); static_assert(std::same_as>); } @@ -456,7 +458,7 @@ auto test_pack() -> void -As a payload, a `copack` models variant-like structures such as lexical tokens or parsed configuration keys. +As a payload, a `copack` models variant-like structures — i.e. a discriminated union of types. To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. Unlike `as_pack`, it always decays: a `copack` alternative can never be a reference. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. +You can lift a `pack` into a `copack` (including `pack` holding references), however you cannot store `copack` inside a `pack`. There is algebraic equivalence between a hypothetical `pack` containing a `copack` (which is disallowed) and a specific shape of `copack` containing a `pack` — see section 6 for details. + ```cpp struct IntegerToken {}; @@ -478,13 +482,17 @@ struct StringToken {}; auto test_copack() -> void { - constexpr fn::copack_for token = IntegerToken{}; + static constexpr fn::copack_for token = IntegerToken{}; // Member apply eliminates the copack by routing the active alternative to an overload set: - constexpr auto value = token.apply(fn::overload{[](IntegerToken) { return 1; }, [](StringToken) { return 2; }}); - + static constexpr auto value + = token.apply(fn::overload{[](IntegerToken) { return 1; }, [](StringToken) { return 2; }}); static_assert(value == 1); + // Storing a pack inside a copack is allowed, including a pack holding a reference: + auto cpr = fn::as_copack(fn::as_pack(value)); + static_assert(std::same_as>>); + // Singular lift and direct value extraction (only allowed for singular copacks): auto cp = fn::as_copack(42); using std::get; @@ -503,7 +511,7 @@ A fundamental safety guarantee of `copack` is **exhaustive matching**. Every ope ## 5. Mapping values and errors -Mapping allows you to change the contained data without altering the structural success/failure shape of the computation. `libfn` uses `transform` (functor map) to operate on the successful channel, and `transform_error` for the error channel. +Mapping allows you to change the contained data without altering the structural success/failure shape of the computation. `libfn` uses `transform` (functor *map*) to operate on the successful channel, and `transform_error` for the error channel. ```cpp @@ -512,12 +520,15 @@ auto mapping_values_and_errors() -> void fn::expected> ex{}; auto mapped_val = ex | fn::transform([](UserId) { return User{}; }); - static_assert(std::same_as>>); + static_assert( + std::same_as>>); - auto mapped_err - = ex | fn::transform_error(fn::overload{[](Missing) { return BadSyntax{}; }, [](IoError e) { return e; }}); + auto mapped_err = ex + | fn::transform_error(fn::overload{[](Missing) { return BadSyntax{}; }, + [](IoError e) { return e; }}); - static_assert(std::same_as>>); + static_assert( + std::same_as>>); } ``` @@ -533,11 +544,11 @@ Key principles of mapping: - `transform` stays within the carrier: the member form never leaves its own carrier family. - Success and error states are rigidly preserved. - A bare `copack` has a member `transform`, allowing mapping across alternatives; being data rather than a carrier, it takes no pipeline verb. -- Heterogeneous branch results inside `transform_error` form a normalized result `copack`. +- Heterogeneous branch results inside `transform_error` or `transform` form a normalized result `copack`. - Applying an error-side operation like `transform_error` to a carrier that has no error side (like `just` or `choice`) is rejected by the compiler. - If a side is uninhabited (`copack<>`), the transformation is well-formed, but vacuous (i.e., a no-op): - `transform_error` on `expected>` is proven unreachable and a no-op. - -When either operand is a `copack`, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s — a `pack` on the other side simply widens each of those `pack`s. The variadic entry point into these data rules is `fn::conjoin(...)`, which folds packs, copacks and scalars; conjoining carriers stays with `operator&`. Note that bare `scalar & scalar` never enters the algebra by itself: for class types it fails to compile, and for built-in types like `int` it resolves to the built-in bitwise AND. To conjoin scalars, lift them with `fn::conjoin(a, b)`, or with `fn::as_pack(a) & b` — the lift has to be the left operand, the side `operator&` dispatches on. +When either operand is a `copack`, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s — a `pack` on the other side simply widens each of those `pack`s. The variadic entry point into these data rules is `fn::conjoin(...)`, which folds packs, copacks and scalars. Carriers need to be conjoined with `operator&`. Note that bare `scalar & scalar` never enters the algebra by itself: for class types it fails to compile, and for built-in types like `int` it resolves to the built-in bitwise AND. To conjoin scalars, lift them with `fn::conjoin(a, b)`, or with `fn::as_pack(a) & b` — the lift has to be the left operand, the side `operator&` dispatches on. ```cpp -auto test_cartesian_distribution() -> void +auto test_cartesian_distribution(fn::copack_for ab, fn::copack_for cd) -> void { - constexpr fn::copack_for ab = A{}; - constexpr fn::copack_for cd = C{}; - + // Cartesian distribution of copacks: (A + B) x (C + D) = (A x C) + (A x D) + (B x C) + (B x D) auto result1 = ab & cd; - static_assert( - std::same_as, fn::pack, fn::pack, fn::pack>>); + std::same_as, fn::pack, fn::pack, fn::pack>>); + // Cartesian distribution of a pack and a copack: (A x B) x (C + D) = (A x B x C) + (A x B x D) constexpr fn::pack Pab = {A{}, B{}}; auto result2 = Pab & cd; - - static_assert(std::same_as, fn::pack>>); + static_assert( + std::same_as, fn::pack>>); } ``` @@ -619,18 +629,15 @@ When performing product composition (`operator&`), you can combine fallible carr copack_for>. Probe: expected & expected> yields expected>, not expected. No alternative is added either way, so the "errors are unaffected" headline stands - the spelling of the error side does change. --> -- **Errors are unaffected**: Because identity cluster operands can never fail, they add no alternative to the result's error channel. A `just` or `choice` operand leaves the fallible operand's error side exactly as it was, plain or copack-graded. An `expected>` operand instead contributes its uninhabited grade to the error union — no alternative is added, but a plain error `E` is thereafter spelled as the singular `copack`. +- **Errors are unaffected**: Because identity cluster operands can never fail, they add no alternative to the result's error channel. A `just` or `choice` operand leaves the fallible operand's error side exactly as it was, plain or graded. An `expected>` operand instead contributes its uninhabited grade to the error union — no alternative is added, but a plain error `E` is thereafter spelled as the singular `copack`. - **Value bundling**: The value of the identity cluster operand is conjoined with the fallible operand's value channel into a `fn::pack`. - **Unit elision**: `just` and `expected>` act as the product's identity unit and are completely elided from the value product (e.g., `expected & just` stays `expected`). - **Choice distribution**: If a `choice` operand is conjoined with a fallible carrier, the coproduct distributes through the product. This yields a `copack` of `pack`s wrapped back inside the fallible carrier. ```cpp -auto test_conjunction_with_identity_cluster() -> void +auto test_conjunction_with_identity_cluster(fn::expected ex, fn::just j) -> void { - fn::expected ex{42}; - fn::just j{1.5}; - // Conjoining an expected with a just auto res1 = ex & j; static_assert(std::same_as, Error>>); @@ -642,8 +649,9 @@ auto test_conjunction_with_identity_cluster() -> void // Conjoining a choice causes distribution inside the carrier fn::choice ch = 1.5; auto res3 = ex & ch; - static_assert( - std::same_as, fn::pack>, Error>>); + static_assert(std::same_as< + decltype(res3), + fn::expected, fn::pack>, Error>>); } ``` @@ -677,8 +685,8 @@ auto operator_or_composition() -> void fn::expected b{}; auto result = a | b; - - static_assert(std::same_as, fn::pack>>); + static_assert(std::same_as, fn::pack>>); } ``` @@ -690,7 +698,7 @@ The runtime and compile-time semantics of disjunction are exact: - `void` results enter a genuine sum as `pack<>`. If both operands are `void`, they collapse back to `void`. - **Error-side product**: - Because the overall disjunction only fails if *both* operands fail, the error channel represents the product of both errors. This is recorded positionally inside `fn::pack`. - ```cpp -auto test_disjoin() -> void +auto test_disjoin(fn::expected a, fn::expected b) -> void { - fn::expected a = 12; - fn::expected b = true; - - // Unary disjoin forwards unchanged (maintaining rvalue value-categories) - static_assert(std::same_as); - - // Multiple fallible and total operands compose cleanly - auto result = fn::disjoin(a, b, fn::just{1.5}); - - // Because just cannot fail, the entire disjunction becomes total - static_assert(std::same_as>); + // Multiple fallible operands compose cleanly + auto res1 = fn::disjoin(a, b); + static_assert(std::same_as, fn::pack>>); + + // Because just cannot fail, the entire disjunction with infallible operands becomes total + auto res2 = fn::disjoin(a, b, fn::just{1.5}); + static_assert(std::same_as>); } ``` @@ -731,11 +736,12 @@ auto test_disjoin() -> void > - **Error multiplication (Product)**: Error channels are composed as a cartesian product ($\otimes$), yielding a `pack` of errors. > - **Product annihilation**: Admitting an identity carrier (whose error side is the initial object $0 \cong \text{copack<>}$) annihilates the error cartesian product: > $$E \times 0 \cong 0$$ -> This mathematical property forces the error channel to collapse, rendering the entire disjunction total (never-failing) and folding the result into the identity cluster. +> +> This mathematical property forces the error channel to collapse, rendering the entire disjunction total (never-failing) and folding the result into the identity cluster. > ## 8. Sequential composition with and_then -Sequential composition chains dependent operations where the success of one feeds the input of the next. In `libfn`, this is achieved using `and_then` (monadic bind). +Sequential composition chains dependent operations where the success of one feeds the input of the next. In `libfn`, this is achieved using `and_then` (monadic *bind*). -A monadic carrier wraps a value. A *Kleisli arrow* is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind — or, from an infallible input, of the fallible kind it bridges to (Section 10). `and_then(f)` produces a storable operation value that can be piped. +A monadic carrier wraps a value. A *Kleisli arrow* is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind — or, from an infallible input, either an infallible carrier or a fallible kind it bridges to (Section 10). -The graded pipeline of Section 1 is this bind chained; here is the rule on its own: +The graded pipeline of Section 1 is this *bind* chained; here is the rule on its own: ```cpp @@ -756,15 +762,15 @@ auto load_user(UserId) -> fn::expected>; auto sequential_bind() -> void { auto result = parse_numeric() | fn::and_then(load_user); - - static_assert(std::same_as>>); + static_assert( + std::same_as>>); } ``` -The strict "same-kind" contract defines how types interact: +The member function `.and_then` cannot perform a carrier conversion — it can only be performed with a functor operation `fn::and_then`, for the same reason member `.or_else` cannot do cross-carrier bridging (section 3). This means that a *Kleisli arrow* passed to `.and_then` has to follow a strict "same-kind" rule in regard to its return type: the "same-kind" contract defines how types interact: - An `optional` binds to an `optional`. - -> In C++, `and_then` implements the bind operation, while `transform` implements the endofunctor map $M(f)$. Two of these laws are checked statically under constant evaluation in Section 14. +> In C++, `and_then` implements the *bind* operation, while `transform` implements the endofunctor *map* $M(f)$. Two of these laws are checked statically under constant evaluation in Section 14. > ## 9. Graded expected: exact error sets @@ -815,21 +821,23 @@ Consider a configuration reader that parses a loosely typed file into specific v ```cpp -auto read_config() - -> fn::expected, fn::copack_for>; +auto read_config() -> fn::expected, + fn::copack_for>; auto config_pipeline() -> void { auto validated = read_config() - | fn::and_then(fn::overload{[](MaximumSize v) { return fn::expected>{v}; }, - [](FilePath v) { return fn::expected>{v}; }, - [](BlockSize v) { return fn::expected>{v}; }}); + | fn::and_then(fn::overload{ + [](MaximumSize v) { return fn::expected>{v}; }, + [](FilePath v) { return fn::expected>{v}; }, + [](BlockSize v) { return fn::expected>{v}; }}); // The result exactly bounds both the successful paths and the error paths static_assert( - std::same_as, - fn::copack_for>>); + std::same_as, + fn::copack_for>>); } ``` @@ -843,11 +851,12 @@ Two independent joins occurred during `and_then`: expected and expected> are same-kind. The parenthetical is accurate and stays: two *different* plain error types are indeed not same_kind (examples/type_algebra/main.cpp:288). --> -This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` admits only the same error type `E`, or its singular lift `copack`, to participate in monadic bind (meaning `expected` and `expected` are **not** `same_kind`), any two graded `expected` types are considered `same_kind`, regardless of how their individual error sets differ: +This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` admits only the same error type `E`, or its singular lift `copack`, to participate in monadic *bind* (meaning `expected` and `expected` are **not** `same_kind`), any two **graded** `expected` types are considered `same_kind`, regardless of how their individual error sets differ (since a union of both sets can always be formed): ```cpp -static_assert(fn::same_kind>, fn::expected>>); +static_assert( + fn::same_kind>, fn::expected>>); ``` It is crucial to distinguish value joining from error grading: @@ -858,8 +867,8 @@ It is crucial to distinguish value joining from error grading: To make composition more user-friendly, `libfn` allows explicit **type promotion** during sequential composition: -- In `and_then` (success binding), a plain error type `E` is automatically promoted to `copack` if the returning error type of the callback is `copack`. -- In `or_else` (recovery/error binding), a plain success type `T` is automatically promoted to `copack` if the returning success type of the callback is `copack`. +- In `and_then` (success binding), a plain error type `E` is automatically promoted to `copack` if the returning **error type** of the callback is `copack`. +- In `or_else` (recovery/error binding), a plain success type `T` is automatically promoted to `copack` if the returning **success type** of the callback is `copack`. This ensures that you can smoothly transition from a simple, un-graded computation to a graded, multi-alternative computation when entering a pipeline step that introduces alternative paths, without needing to manually wrap or lift your starting types. @@ -902,7 +911,8 @@ Recovery via `or_else` behaves symmetrically. It handles input error alternative ### Widening is subeffecting -In accordance with the subeffecting principles of graded monads (Section 1), a narrow error set can be safely widened during composition, but narrowing requires explicit mitigation. Implicit narrowing (without handling the removed errors) is unsafe and rejected by the compiler. -The bind operation adopts the carrier family of the provided callback. However, the member `and_then` remains strict to its own carrier family. The pipeline-scoped functors are the licensed cross-carrier place; the members stay uncoupled from one another. - -Furthermore, fallible types like `expected` (with inhabited error states) and `optional` cannot indiscriminately switch to other carriers, because doing so would risk silently discarding an inhabited state. +The *bind* operation adopts the carrier family of the provided callback. However, the member `.and_then` remains strict to its own carrier family (for reasons explained in section 3). The pipeline-scoped functors are the licensed cross-carriers. ### Success-Path Bridging +Fallible types like `expected` (with inhabited error states — `expected>` excluded) and `optional` cannot switch to infallible carriers, because doing so would risk silently discarding an inhabited (i.e. error) state. + -While standard fallible carriers cannot change families on the success path (Section 3), **identity carriers are licensed to bridge to any fallible carrier** via the pipeline `and_then`. Because an identity carrier is statically proven infallible, transitioning to `optional` or a standard `expected` merely introduces potential failure downstream. No pre-existing failure state is discarded, because none can exist upstream: +However, identity carriers are licensed to bridge to any fallible carrier via the pipeline `fn::and_then`. Because an identity carrier is statically proven infallible, transitioning to `optional` or a standard `expected` merely introduces potential failure downstream. No pre-existing failure state is discarded, because none can exist upstream: ```cpp -auto test_success_bridge() -> void +auto test_success_bridge(fn::just j) -> void { - fn::just j{1}; - // An identity carrier can bridge to fallible carriers on the success path auto to_opt = j | fn::and_then([](int i) { return fn::optional{i}; }); static_assert(std::same_as>); @@ -1006,14 +1014,15 @@ auto test_success_bridge() -> void static_assert(std::same_as>); // Bridging a multi-alternative choice to fallible optional with heterogeneous success join: - auto choice_to_opt = fn::choice_for{true} - | fn::and_then(fn::overload{[](int) -> fn::optional { return {'a'}; }, - [](bool) -> fn::optional { return {2L}; }}); + auto choice_to_opt + = fn::choice_for{true} + | fn::and_then(fn::overload{[](int) -> fn::optional { return {'a'}; }, + [](bool) -> fn::optional { return {2L}; }}); static_assert(std::same_as>>); } ``` -All three cluster members bridge this way: `just`, `choice`, and `expected>`. +All three cluster members (`just`, `choice`, and `expected>`) can bridge to fallible carriers. Monadic operations behave naturally around this identity cluster: @@ -1027,7 +1036,7 @@ Monadic operations behave naturally around this identity cluster: - **Sequential binding (`and_then`)**: Allows cross-carrier transitions *within* the identity cluster (e.g., `just` to `expected>`) when using pipeline-scoped `fn::and_then`. - **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable (to allow generic code on `expected` to compile). - **Short-circuiting (`fail`, `filter`)**: Strictly rejected for all identity cluster carriers, because no failure state (an inhabited error or empty state) can possibly be constructed from a never-failing identity context. - ```cpp -auto test_identity_transformation() -> void +auto test_identity_transformation(fn::just j) -> void { - fn::just j{UserId{}}; - // Transforming a just with a callable returning a copack produces a choice - auto mapped = j | fn::transform([](UserId) { return fn::copack_for{Missing{}}; }); + auto mapped + = j | fn::transform([](UserId) { return fn::copack_for{Missing{}}; }); static_assert(std::same_as>); } @@ -1110,21 +1118,18 @@ Consider a scenario where different branches of a switch return different `choic ```cpp -auto test_choice_mapping() -> void +auto test_choice_mapping(fn::choice ch) -> void { - fn::choice ch{UserId{}}; - constexpr auto mapper = fn::overload{[](UserId) { return fn::choice{Missing{}}; }, [](User) { return fn::choice{FilePath{}}; }}; // transform nests the returned choice as a mapped value auto mapped = ch | fn::transform(mapper); - - static_assert(std::same_as, fn::choice>>); + static_assert( + std::same_as, fn::choice>>); // and_then joins and flattens them into a normalized superset choice auto bound = ch | fn::and_then(mapper); - static_assert(std::same_as>); } ``` @@ -1134,7 +1139,7 @@ auto test_choice_mapping() -> void fires a named diagnostic on instantiation rather than dropping out of the overload set (choice.hpp:695; tests/fn/choice.cpp:1050-1054 asserts can_and_then holds precisely so the static_assert can speak). --> -A callback returning a bare value belongs to `transform`, not `and_then`: `choice`'s `and_then` rejects it, and does so with a named diagnostic rather than by silently dropping out of the overload set. +A callback returning a bare value belongs to `transform`, not `and_then`: `choice`'s `and_then` rejects it with a named diagnostic. > [!TIP] > @@ -1159,12 +1164,17 @@ A callback returning a bare value belongs to `transform`, not `and_then`: `choic > ## 12. Elimination and multidispatch -Once your computation shapes are fully derived, you must eliminate the structure to yield an ordinary C++ value. This is done via `apply` or `apply_r`. + +Once your computation shapes are fully derived, you may want to eliminate the structure to yield an ordinary C++ value. This is typically done via `apply` or `apply_r`. You can also use `get` on a singular `copack` (as explained in section 4); or directly read `.value()` from a `just`, where it is total. On fallible carriers `.value()` is partial: it yields the value if there is one, and otherwise throws (`bad_expected_access`, `bad_optional_access`). It is vital to distinguish `transform` from `apply`: - `transform` stays *inside* the carrier or copack, producing a new carried type. - - `apply` *eliminates* the structure entirely: the result type is deduced from the branches, which must then all yield that one same type. @@ -1213,7 +1223,7 @@ Exhaustiveness is statically constrained. If you omit a handler for a possible t -Because storage shape and call shape are distinct, untagged `apply` can sometimes erase the structural context of the state. To preserve this context and prevent permissive C++ implicit conversions from accidentally conflating different states, `libfn` provides the **`apply_type`** (and `apply_type_r`) member functions. +Because storage shape and call shape are distinct, untagged `apply` can sometimes erase the structural context of the state (for example from `expected`). To preserve this context and prevent permissive C++ implicit conversions from accidentally conflating different states, `libfn` provides the **`apply_type`** (and `apply_type_r`) member functions. When you eliminate a carrier using `apply_type`, the active handler receives an explicit C++ state tag or constructor tag as its first argument, followed by the unpacked payload: @@ -1244,7 +1254,7 @@ This is a concise reference for `libfn`'s operations, organized by channel and e - `transform`: Maps the successful value. Stays inside the carrier. - `and_then`: Sequences success-path computations. The mechanism for introducing new errors into a graded expected. - - `filter`: Enters a short-circuit state if a predicate fails. @@ -1291,7 +1301,7 @@ To reason about how these operations affect the type algebra of your computation - **`fail` and `recover` are dual symmetries**: `fail` intercepts a success-path value and forces a transition to the failure state ($Success \implies Failure$). `recover` intercepts a failure-path error and forces a transition back to the success state ($Failure \implies Success$). Neither operation widens the error set of a graded carrier. - **Graded `and_then`** is the primary mechanism for introducing a _new_ error type (widening the error grade) into your pipeline. - **`filter` and `fail`** merely enter an _existing_ short-circuit state: the carrier must already be capable of holding the failure state. - optionally prefixed by blockquote indicators like '> ' + # followed by ```cpp ```, and replace it while preserving the prefix on every line. + def replace_snippet(match): + prefix = match.group(1) + name = match.group(2) + if name not in regions: + sys.stderr.write(f"Warning: no matching region in main.cpp for sync-example-{name}\n") + return match.group(0) # Keep unchanged + + # Prefix each line of the synchronized example block if we are inside a blockquote + region_lines = regions[name].splitlines() + if prefix: + prefixed_region = "\n".join(f"{prefix}{line}" if line.strip() else prefix.rstrip() for line in region_lines) + else: + prefixed_region = "\n".join(region_lines) + + return f"{prefix}\n{prefix}```cpp\n{prefixed_region}\n{prefix}```" + + # Match: optional blockquote prefix (e.g., '> '), comment, opening fence, body, and closing fence + pattern = re.compile( + r"^([ >]*?)\s*?\n[ >]*?```cpp\n(.*?)\n[ >]*?```", + re.MULTILINE | re.DOTALL + ) + updated_text = pattern.sub(replace_snippet, doc_text) + + # Clean up trailing whitespace in the document (like pre-commit trailing whitespace check does) + updated_text = re.sub(r"[ \t]+$", "", updated_text, flags=re.MULTILINE) + + if doc_text != updated_text: + doc_path.write_text(updated_text, encoding="utf-8") + print(f"Synced code fences in {doc_path.relative_to(repo)} <- {example_path.relative_to(repo)}") + return True # changed + return False # no change + +def main() -> None: + parser = argparse.ArgumentParser(description="Synchronize markdown files with verified C++ examples.") + parser.add_argument("mode", choices=["readme", "type-algebra"], help="The markdown file/examples to sync.") + args = parser.parse_args() + + repo = pathlib.Path(__file__).resolve().parents[1] + + if args.mode == "readme": + changed = sync_readme(repo) + elif args.mode == "type-algebra": + changed = sync_type_algebra(repo) + else: + sys.stderr.write(f"Unknown mode: {args.mode}\n") + sys.exit(2) + + if changed: + sys.exit(1) + + print(f"All {args.mode} code examples are fully synchronized!") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/scripts/sync_readme_example.py b/scripts/sync_readme_example.py deleted file mode 100644 index 1a4732a0..00000000 --- a/scripts/sync_readme_example.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -"""Keep the README.md code example in sync with examples/readme/main.cpp. - -examples/readme/main.cpp is the single source of truth: CI builds and runs it, -so the code README.md shows is proven to compile and pass its own checks. The -region between its two `// readme-example` marker lines is mirrored into the -```cpp fence under README.md's "## Example" heading. Run as a pre-commit hook: -it rewrites the fence and exits non-zero if it changed anything, so the commit -is blocked until the change is re-staged. -""" -import pathlib -import sys - -MARKER = "// readme-example" -HEADING = "## Example" - -repo = pathlib.Path(__file__).resolve().parents[1] -example_path = repo / "examples" / "readme" / "main.cpp" -readme_path = repo / "README.md" - -lines = example_path.read_text(encoding="utf-8").splitlines(keepends=True) -marks = [i for i, line in enumerate(lines) if line.rstrip("\r\n") == MARKER] -if len(marks) != 2: - sys.stderr.write( - f"{example_path.relative_to(repo)}: expected exactly two {MARKER!r} lines, found {len(marks)}\n" - ) - sys.exit(1) -region = lines[marks[0] + 1 : marks[1]] - -readme = readme_path.read_text(encoding="utf-8").splitlines(keepends=True) -try: - heading = next(i for i, line in enumerate(readme) if line.rstrip("\r\n") == HEADING) - fence = next(i for i in range(heading + 1, len(readme)) if readme[i].rstrip("\r\n") == "```cpp") - close = next(i for i in range(fence + 1, len(readme)) if readme[i].rstrip("\r\n") == "```") -except StopIteration: - sys.stderr.write( - f"{readme_path.relative_to(repo)}: could not find {HEADING!r} followed by a ```cpp fence\n" - ) - sys.exit(1) - -if readme[fence + 1 : close] != region: - readme[fence + 1 : close] = region - readme_path.write_text("".join(readme), encoding="utf-8") - print(f"synced {readme_path.relative_to(repo)} example <- {example_path.relative_to(repo)}") - sys.exit(1) -sys.exit(0) diff --git a/scripts/sync_type_algebra_examples.py b/scripts/sync_type_algebra_examples.py deleted file mode 100755 index 92078ab5..00000000 --- a/scripts/sync_type_algebra_examples.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -"""Keep TYPE_ALGEBRA.md code examples in sync with examples/type_algebra/main.cpp. - -examples/type_algebra/main.cpp is the single source of truth: CI builds and runs it, -proving that all examples are compilable. The regions bounded by `// sync-example-` -are mirrored into the matching `` code fences in TYPE_ALGEBRA.md. -""" -import pathlib -import re -import sys - -repo = pathlib.Path(__file__).resolve().parents[1] -example_path = repo / "examples" / "type_algebra" / "main.cpp" -doc_path = repo / "TYPE_ALGEBRA.md" - -if not example_path.exists(): - sys.stderr.write(f"Error: {example_path} does not exist\n") - sys.exit(1) - -if not doc_path.exists(): - sys.stderr.write(f"Error: {doc_path} does not exist\n") - sys.exit(1) - -# 1. Parse all regions from examples/type_algebra/main.cpp -example_text = example_path.read_text(encoding="utf-8") -example_lines = example_text.splitlines() - -regions = {} -current_name = None -current_block = [] - -for line in example_lines: - stripped = line.strip() - if stripped.startswith("// sync-example-"): - name = stripped[len("// sync-example-"):] - if current_name is None: - # Start of a region - current_name = name - current_block = [] - elif current_name == name: - # End of a region - regions[current_name] = "\n".join(current_block) - current_name = None - else: - sys.stderr.write(f"Error: unmatched boundary in main.cpp: started {current_name}, got {name}\n") - sys.exit(1) - elif current_name is not None: - current_block.append(line) - -print(f"Extracted {len(regions)} verified code regions from {example_path.name}") - -# 2. Read and synchronize TYPE_ALGEBRA.md -doc_text = doc_path.read_text(encoding="utf-8") - -# We find optionally prefixed by blockquote indicators like '> ' -# followed by ```cpp ```, and replace it while preserving the prefix on every line. -def replace_snippet(match): - prefix = match.group(1) - name = match.group(2) - if name not in regions: - sys.stderr.write(f"Warning: no matching region in main.cpp for sync-example-{name}\n") - return match.group(0) # Keep unchanged - - # Prefix each line of the synchronized example block if we are inside a blockquote - region_lines = regions[name].splitlines() - if prefix: - prefixed_region = "\n".join(f"{prefix}{line}" if line.strip() else prefix.rstrip() for line in region_lines) - else: - prefixed_region = "\n".join(region_lines) - - return f"{prefix}\n{prefix}```cpp\n{prefixed_region}\n{prefix}```" - -# Match: optional blockquote prefix (e.g., '> '), comment, opening fence, body, and closing fence -pattern = re.compile( - r"^([ >]*?)\s*?\n[ >]*?```cpp\n(.*?)\n[ >]*?```", - re.MULTILINE | re.DOTALL -) -updated_text = pattern.sub(replace_snippet, doc_text) - -# Clean up trailing whitespace in the document (like pre-commit trailing whitespace check does) -updated_text = re.sub(r"[ \t]+$", "", updated_text, flags=re.MULTILINE) - -if doc_text != updated_text: - doc_path.write_text(updated_text, encoding="utf-8") - print(f"Synced code fences in {doc_path.relative_to(repo)} <- {example_path.relative_to(repo)}") - sys.exit(1) - -print("All TYPE_ALGEBRA.md code examples are fully synchronized!") -sys.exit(0) From 3d6dc8ab52a2955a05033f208f829d6bf2c29f07 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 14:50:36 +0100 Subject: [PATCH 18/51] Generalize markdown example sync into a single mechanism Assisted-by: Claude:claude-opus-5 --- .pre-commit-config.yaml | 9 +- README.md | 1 + examples/readme/main.cpp | 4 +- scripts/sync_md_examples.py | 222 ++++++++++++++++-------------------- 4 files changed, 106 insertions(+), 130 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5e9fe9d..e2b6d16d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,15 +61,16 @@ repos: language: python files: ^include/.*\.hpp$ pass_filenames: false + # NOTE each document and its example must match a DOCUMENTS entry in scripts/sync_md_examples.py - id: sync-readme-example - name: sync README example with examples/readme - entry: python scripts/sync_md_examples.py readme + name: sync README code fences with examples/readme + entry: python scripts/sync_md_examples.py README.md language: python files: ^(README\.md|examples/readme/main\.cpp)$ pass_filenames: false - id: sync-type-algebra-examples - name: sync TYPE_ALGEBRA examples with examples/type_algebra - entry: python scripts/sync_md_examples.py type-algebra + name: sync TYPE_ALGEBRA code fences with examples/type_algebra + entry: python scripts/sync_md_examples.py TYPE_ALGEBRA.md language: python files: ^(TYPE_ALGEBRA\.md|examples/type_algebra/main\.cpp)$ pass_filenames: false diff --git a/README.md b/README.md index b0772197..f6e2c3b6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The purpose of this library is to exercise an approach to functional programming ## Example + ```cpp // Various error types. enum class NotANumber {}; diff --git a/examples/readme/main.cpp b/examples/readme/main.cpp index 8b95115b..fbcb727d 100644 --- a/examples/readme/main.cpp +++ b/examples/readme/main.cpp @@ -91,7 +91,7 @@ constexpr auto parse(std::string_view s) noexcept return fn::pack{n, d}; } -// readme-example +// sync-example-readme // Various error types. enum class NotANumber {}; enum class DivByZero {}; @@ -187,7 +187,7 @@ static_assert( // Constant evaluated calculations used to verify both values and errors during compilation: static_assert(evaluate("1/2", Add{}, "1/3").value() == Rational::make(5, 6)); static_assert(evaluate("2/3", Div{}, "0/1").error().has_value()); -// readme-example +// sync-example-readme int main() { diff --git a/scripts/sync_md_examples.py b/scripts/sync_md_examples.py index e46d1fae..80ab6ae1 100755 --- a/scripts/sync_md_examples.py +++ b/scripts/sync_md_examples.py @@ -1,147 +1,121 @@ #!/usr/bin/env python3 -"""Keep markdown document code examples in sync with their verified C++ source files.""" +"""Keep markdown code fences in sync with the compiled C++ examples they quote. + +A fence is quoted from a named region of an example: the example brackets the region with a pair of +`// sync-example-` comments, the document places `` immediately +above the ```cpp fence receiving it. A fence inside a blockquote keeps its `> ` prefix. +""" +from __future__ import annotations + import argparse import pathlib import re import sys -def sync_readme(repo: pathlib.Path) -> bool: - MARKER = "// readme-example" - HEADING = "## Example" - - example_path = repo / "examples" / "readme" / "main.cpp" - readme_path = repo / "README.md" - - if not example_path.exists(): - sys.stderr.write(f"Error: {example_path} does not exist\n") - sys.exit(2) - if not readme_path.exists(): - sys.stderr.write(f"Error: {readme_path} does not exist\n") - sys.exit(2) - - lines = example_path.read_text(encoding="utf-8").splitlines(keepends=True) - marks = [i for i, line in enumerate(lines) if line.rstrip("\r\n") == MARKER] - if len(marks) != 2: - sys.stderr.write( - f"{example_path.relative_to(repo)}: expected exactly two {MARKER!r} lines, found {len(marks)}\n" - ) - sys.exit(2) - region = lines[marks[0] + 1 : marks[1]] - - readme = readme_path.read_text(encoding="utf-8").splitlines(keepends=True) - try: - heading = next(i for i, line in enumerate(readme) if line.rstrip("\r\n") == HEADING) - fence = next(i for i in range(heading + 1, len(readme)) if readme[i].rstrip("\r\n") == "```cpp") - close = next(i for i in range(fence + 1, len(readme)) if readme[i].rstrip("\r\n") == "```") - except StopIteration: - sys.stderr.write( - f"{readme_path.relative_to(repo)}: could not find {HEADING!r} followed by a ```cpp fence\n" - ) - sys.exit(2) - - if readme[fence + 1 : close] != region: - readme[fence + 1 : close] = region - readme_path.write_text("".join(readme), encoding="utf-8") - print(f"synced {readme_path.relative_to(repo)} example <- {example_path.relative_to(repo)}") - return True # changed - return False # no change - -def sync_type_algebra(repo: pathlib.Path) -> bool: - example_path = repo / "examples" / "type_algebra" / "main.cpp" - doc_path = repo / "TYPE_ALGEBRA.md" - - if not example_path.exists(): - sys.stderr.write(f"Error: {example_path} does not exist\n") - sys.exit(2) - if not doc_path.exists(): - sys.stderr.write(f"Error: {doc_path} does not exist\n") - sys.exit(2) - - # 1. Parse all regions from examples/type_algebra/main.cpp - example_text = example_path.read_text(encoding="utf-8") - example_lines = example_text.splitlines() - - regions = {} - current_name = None - current_block = [] - - for line in example_lines: - stripped = line.strip() - if stripped.startswith("// sync-example-"): - name = stripped[len("// sync-example-"):] - if current_name is None: - # Start of a region - current_name = name - current_block = [] - elif current_name == name: - # End of a region - regions[current_name] = "\n".join(current_block) - current_name = None - else: - sys.stderr.write(f"Error: unmatched boundary in main.cpp: started {current_name}, got {name}\n") - sys.exit(2) - elif current_name is not None: - current_block.append(line) - - print(f"Extracted {len(regions)} verified code regions from {example_path.name}") - - # 2. Read and synchronize TYPE_ALGEBRA.md - doc_text = doc_path.read_text(encoding="utf-8") - - # We find optionally prefixed by blockquote indicators like '> ' - # followed by ```cpp ```, and replace it while preserving the prefix on every line. - def replace_snippet(match): - prefix = match.group(1) - name = match.group(2) - if name not in regions: - sys.stderr.write(f"Warning: no matching region in main.cpp for sync-example-{name}\n") - return match.group(0) # Keep unchanged - - # Prefix each line of the synchronized example block if we are inside a blockquote - region_lines = regions[name].splitlines() - if prefix: - prefixed_region = "\n".join(f"{prefix}{line}" if line.strip() else prefix.rstrip() for line in region_lines) +# Markdown document -> the compiled example its fences quote. Keep the `files` pattern of the +# sync-md-examples hook in .pre-commit-config.yaml covering exactly these paths. +DOCUMENTS = { + "README.md": "examples/readme/main.cpp", + "TYPE_ALGEBRA.md": "examples/type_algebra/main.cpp", +} + +BOUNDARY = "// sync-example-" + +# Blockquote prefix, anchor comment, and the fence it introduces, body captured non-greedily. +FENCE = re.compile( + r"^([ >]*)[ \t]*\n[ >]*```cpp\n(.*?)\n[ >]*```[ \t]*$", + re.MULTILINE | re.DOTALL, +) + + +def fail(message: str) -> None: + sys.stderr.write(f"Error: {message}\n") + sys.exit(2) + + +def read(path: pathlib.Path, repo: pathlib.Path) -> str: + if not path.exists(): + fail(f"{path.relative_to(repo)} does not exist") + return path.read_text(encoding="utf-8") + + +def extract(example: pathlib.Path, repo: pathlib.Path) -> dict[str, list[str]]: + """Collect the regions an example offers for quotation, keyed by name.""" + where = example.relative_to(repo) + regions: dict[str, list[str]] = {} + open_name: str | None = None + body: list[str] = [] + + for lineno, line in enumerate(read(example, repo).splitlines(), start=1): + boundary = line.strip() + if not boundary.startswith(BOUNDARY): + if open_name is not None: + body.append(line) + continue + name = boundary[len(BOUNDARY) :] + if open_name is None: + if name in regions: + fail(f"{where}:{lineno}: region {name!r} opened again after it was closed") + open_name, body = name, [] + elif name == open_name: + regions[open_name] = body + open_name = None else: - prefixed_region = "\n".join(region_lines) + fail(f"{where}:{lineno}: region {open_name!r} closed by {name!r}") - return f"{prefix}\n{prefix}```cpp\n{prefixed_region}\n{prefix}```" + if open_name is not None: + fail(f"{where}: region {open_name!r} is never closed") + return regions - # Match: optional blockquote prefix (e.g., '> '), comment, opening fence, body, and closing fence - pattern = re.compile( - r"^([ >]*?)\s*?\n[ >]*?```cpp\n(.*?)\n[ >]*?```", - re.MULTILINE | re.DOTALL - ) - updated_text = pattern.sub(replace_snippet, doc_text) - # Clean up trailing whitespace in the document (like pre-commit trailing whitespace check does) - updated_text = re.sub(r"[ \t]+$", "", updated_text, flags=re.MULTILINE) +def sync(document: pathlib.Path, example: pathlib.Path, repo: pathlib.Path) -> bool: + """Rewrite the document's anchored fences from the example; report whether anything changed.""" + regions = extract(example, repo) + quoted: set[str] = set() + + def quote(match: re.Match) -> str: + prefix, name, _ = match.groups() + if name not in regions: + fail(f"{document.relative_to(repo)}: no region {name!r} in {example.relative_to(repo)}") + quoted.add(name) + body = "\n".join(f"{prefix}{line}".rstrip() for line in regions[name]) + return f"{prefix}\n{prefix}```cpp\n{body}\n{prefix}```" + + text = read(document, repo) + updated = FENCE.sub(quote, text) + + for name in sorted(set(regions) - quoted): + sys.stderr.write(f"Warning: region {name!r} of {example.relative_to(repo)} is never quoted\n") + + if text == updated: + return False + document.write_text(updated, encoding="utf-8") + print(f"synced {document.relative_to(repo)} <- {example.relative_to(repo)}") + return True - if doc_text != updated_text: - doc_path.write_text(updated_text, encoding="utf-8") - print(f"Synced code fences in {doc_path.relative_to(repo)} <- {example_path.relative_to(repo)}") - return True # changed - return False # no change def main() -> None: - parser = argparse.ArgumentParser(description="Synchronize markdown files with verified C++ examples.") - parser.add_argument("mode", choices=["readme", "type-algebra"], help="The markdown file/examples to sync.") + parser = argparse.ArgumentParser(description="Synchronize markdown files with compiled C++ examples.") + parser.add_argument( + "documents", + nargs="*", + default=list(DOCUMENTS), + help=f"documents to synchronize, any of: {', '.join(DOCUMENTS)} (default: all)", + ) args = parser.parse_args() repo = pathlib.Path(__file__).resolve().parents[1] - - if args.mode == "readme": - changed = sync_readme(repo) - elif args.mode == "type-algebra": - changed = sync_type_algebra(repo) - else: - sys.stderr.write(f"Unknown mode: {args.mode}\n") - sys.exit(2) + changed = False + for name in args.documents: + if name not in DOCUMENTS: + fail(f"unknown document {name!r}, expected one of: {', '.join(DOCUMENTS)}") + changed |= sync(repo / name, repo / DOCUMENTS[name], repo) if changed: sys.exit(1) - print(f"All {args.mode} code examples are fully synchronized!") - sys.exit(0) + print(f"All code examples in {', '.join(args.documents)} are fully synchronized!") + if __name__ == "__main__": main() From 78ecdc1bbddef64306aea5220bee56fc02436879 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 15:13:08 +0100 Subject: [PATCH 19/51] Remove why stanzas and polish C++ prose in TYPE_ALGEBRA.md Remove all 61 developer-review 'why' annotations. Systematically polish and refine the adjacent prose to adhere to standard WG21-level C++ terminology, including clarifications on compiler-specific type sorting, empty copack constraint-selected overloads, raw reference payload prohibitions on expected/choice/just, and singular monadic lift. Assisted-by: Claude:gemini-1.5-pro --- TYPE_ALGEBRA.md | 345 ++---------------------------------------------- 1 file changed, 14 insertions(+), 331 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index eae3e40c..e6054a45 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1,16 +1,7 @@ # Type algebra and functional composition in libfn - `libfn` is a C++20 functional programming library that lets the compiler derive the complete static shape of a computation alongside its values. Rather than collapsing failure into one wide error type, `libfn` tracks the precise algebraic combinations of success, alternative, and error states during composition. - The library operates on two payload types and four computation carriers: - `pack`: Product type containing all fields; a tuple-like data structure. @@ -20,20 +11,10 @@ The library operates on two payload types and four computation carriers: - `choice`: Never-failing computation holding one of several alternatives. - `just`: Never-failing computation yielding a single value or a `void`. - Composition operations include `transform` (mapping), `transform_error` (error mapping), `and_then` (sequential monadic binding), `or_else` (recovery), `operator&` (conjunction / simultaneous product composition), `operator|` (disjunction / simultaneous sum composition), and the n-ary folds `fn::conjoin` and `fn::disjoin`. Elimination is `apply` (multidispatch). ### Member vs. Pipeline Syntax - Some operations are exposed in two forms: - **Member functions** (e.g., `.transform()`, `.and_then()`, `.apply()`) called directly on a carrier or payload (e.g., `ex.transform(f)`, `cp.apply(f)`). @@ -41,17 +22,8 @@ Some operations are exposed in two forms: There are also pipeline functors (`recover`, `fail`, `filter`, `inspect`, `inspect_error`, `discard`) which have no member spelling, and member functions (`apply`, `apply_r`, `apply_type`) which have no pipeline spelling. - The `operator|` carries two meanings, told apart by its right operand: a pipeline functor on the right feeds the carrier into that operation, while another carrier on the right is disjunction (explained in Section 7). - Freestanding `fn::apply(f, args...)` is the general multidispatch entry point: it accepts any mix of scalars, tuple-like structures, `pack`s and `copack`s, unpacking products and dispatching over alternatives in a single call. Do not confuse with `pfn::apply`, which is a polyfill for the C++26 `std::apply`, meant for C++20 compilers. The `fn::apply` is an extension on top of `pfn::apply`. In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected`, `pack`) when referring to both forms or core vocabulary types generally. @@ -111,13 +83,6 @@ The resulting `expected` statically records that the pipeline yields a `User` on Standard monads are rigid: an `expected` requires every step in a pipeline to return the identical error type `E`. This forces you to define a monolithic global error union upfront. A **graded monad** relaxes this restriction. Each operation is indexed by a "grade"—a set representing all its specific possible errors (its "effects") by means of `copack`, which is a disjoint set of types. As you chain operations, the compiler automatically adds these grades to the set. - The resulting error type is **graded**: it expands (or narrows during recovery) to match the *exact* subset of errors possible in the compiled path, providing strict static effect tracking (subeffecting) with zero boilerplate. You may also use `copack` on a value side of most carriers (except for `just>`, which must be spelled `choice`). Grading is opt-in: a `copack` on the error side enrols an `expected` in this union arithmetic; a plain `expected` keeps the rigid single-error contract. Similarly, a `copack` on value side enrolls an `expected` or `optional` into union arithmetics on values. @@ -165,10 +130,6 @@ Behind these precise compiled types are two independent mechanisms that cooperat 1. **Type algebra** records and normalizes the exact stored C++ types using `pack` and `copack` as you compose operations. 2. **The application protocol** uses `apply` and ordinary C++ overload resolution to unpack those stored values and route them to your functions or lambdas. - To route multiple alternative paths inside `apply`, the library provides `fn::overload`, which fuses unrelated lambdas into a single overload set. These derived types are the actual explanation of the library's design, not an internal template-metaprogramming implementation detail. Understanding the precise algebraic rules of this type algebra and the mechanics of application is key to mastering the `libfn` library. @@ -184,13 +145,6 @@ To derive strict programmatic shapes, `libfn` uses an algebraic vocabulary over These states can also be used to express the standard vocabulary types: - - `std::optional` ≅ **1 + T** (It is either empty/unit or contains `T`, similar to `copack_for`) - `std::expected` ≅ **T + E** (It contains either success `T` or error `E`, similar to `copack_for>`) @@ -242,13 +196,7 @@ auto test_copack_set_semantics() -> void > > To enforce strict, mathematically sound set semantics at compile time, `libfn` defines a single, strict canonical representation and actively rejects any instantiation that diverges from it: > -> -> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict total order over types. The order is derived from the compiler's own spelling of each type; a build targeting C++26 with `LIBFN_CXX26` set will use `std::type_order` to derive the order of types, while a build for older compilers will use a limited emulation implemented in `libfn` (the two orders may be different and form separate ABIs). If you attempt to instantiate `copack` manually with out-of-order parameters (such as `copack` when `A` precedes `B` in that order) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** +> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict total order over types. The order is derived from the compiler's own spelling of each type; a build targeting C++26 with `LIBFN_CXX26` set will use `std::type_order` to derive the order of types, while the default build uses a type sorting mechanism based on compiler-specific type names (since these two orders may differ, each defines a distinct ABI namespace). If you attempt to instantiate `copack` manually with out-of-order parameters (such as `copack` when `A` precedes `B` in that order) or with nested copacks (such as `copack>`), **the compiler will reject the instantiation as outright ill-formed.** > > - **`copack_for`** is the user-facing type alias utility. It acts as the compile-time "compiler gateway," accepting any raw, arbitrary list of types (out-of-order, duplicates, nested copacks), performing the complex compile-time flattening, deduplication, and canonical sorting automatically, and resolving directly to the validated canonical `copack` type. > @@ -265,14 +213,7 @@ The laws governing `copack` are: - **Idempotent**: Duplicate types are collapsed into one. - **Identity**: `copack<>` acts as the union unit (adding `copack<>` changes nothing). - -Idempotence means a set cannot carry positional meaning: `copack_for` is one alternative, `copack`, not two. Types combined into a `copack` should therefore be strongly typed tag structs or distinct domain objects, never generic primitives whose meaning depends on where they sit. Two *distinct* types that the ordering of types in `copack` cannot tell apart are a different matter: the library rejects them outright rather than merging them, so nothing is ever silently lost. This limitation in particular affects the builds without access to C++26 `std::type_order`, because the limited emulation of type ordering implemented in `libfn` does not have access to full type information. +Idempotence means a set cannot carry positional meaning: `copack_for` is one alternative, `copack`, not two. Types combined into a `copack` should therefore be strongly typed tag structs or distinct domain objects, never generic primitives whose meaning depends on where they sit. Two *distinct* types that the ordering mechanism cannot distinguish are a different matter: the library rejects them outright via a compile-time assertion rather than silently merging them, guaranteeing that no type is ever silently lost. ### The algebra is strictly opt-in @@ -289,11 +230,6 @@ To invoke the algebra, you use the opt-in mechanisms provided by the library: - Explicit conversions via `fn::as_pack` and `fn::as_copack`. - Member helpers for explicit type lifting (detailed in Section 9). - If a side is already a `copack` or `pack`, forwarding it behaves naturally without nesting. A `copack` on an `expected`'s error side is the opt-in to error-set unioning; Section 9 gives the exact promotion rules. > [!TIP] @@ -322,8 +258,6 @@ To model computation and manage control flow (success, failure, alternatives, an - **`expected`** (representing $T + E$): A carrier that either holds a successful value of type `T` or an error of type `E`. - As explained below, `expected` can be also infallible, if its error side is a `copack<>`. - *(Note: `optional` and `expected` are the `fn` extensions of the standards-conforming `pfn` polyfills; Section 15 covers the two layers.)* ### The infallible (identity) carriers @@ -333,12 +267,7 @@ To model computation and manage control flow (success, failure, alternatives, an Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. - - -Additionally, among the infallible types is also **`expected>`** (representing $T + 0 \cong T$). It is a state of `expected` which can never fail, because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. It is **not** a separate computation carrier from `expected`. +Additionally, the infallible state **`expected>`** (representing $T + 0 \cong T$) can never fail because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. Since it is a specialized state of `expected` rather than a unique template, it is classified under the same computation carrier. > [!NOTE] > @@ -348,11 +277,7 @@ Additionally, among the infallible types is also **`expected>`** (re > > In fact, attempting to instantiate `just>` will trigger a compile-time static assertion failure inside `just`, explicitly warning the programmer: `"a just over a copack is spelled choice"`. - -These carriers constrain their payloads. `optional` is supported and well-defined; the other carriers hold references only inside a `pack` (Section 15). +These carriers constrain their payloads. While `optional` is supported as a standard-conforming exception, other carriers reject raw reference types outright; references must be wrapped inside a `pack` (detailed in Section 4). ### Carriers have control flow; raw data does not @@ -362,14 +287,6 @@ To compose computations, we must wrap these values inside **computation carriers ### Carrier Bridging: Interoperable Pipelines - Because these carriers represent different computational contexts, pipelines often need to transition between them. `libfn` licenses explicit **cross-carrier bridging** via pipeline-scoped operations using `operator|`. Standard fallible carriers can bridge to each other on their error/empty recovery paths via pipeline functor `fn::or_else` (e.g., `expected` to `optional`, or vice versa). This is safe because on the success path, the successful value is preserved and bypasses the recovery callback entirely. The transition only occurs on the handled failure branch, allowing you to gracefully convert a missing value into a concrete error, or decay a detailed error into an empty state: @@ -397,19 +314,10 @@ While the computation carriers manage control flow and fallibility, modeling mor ### pack: all fields are present - A `pack` acts like a standard C++ tuple (`std::tuple`) by storing multiple fields and supporting standard tuple protocol (`get`, `tuple_size`, `tuple_element`, structured bindings), and an `append` mechanism. However, unlike standard tuples, `libfn` packs are strictly flat: a `pack` is not a valid element of a `pack`, so `append`-ing one splices its fields into the outer pack rather than nesting it. To explicitly lift values into a `pack` (which is useful when conjoining scalars with other packs or copacks), use `fn::as_pack(...)`. When called without template parameters, `as_pack` is deduction-only and preserves the value category of its arguments: `as_pack(42)` yields `pack`, whereas calling `as_pack(x)` on an lvalue `x` yields `pack` (a reference rather than a copy). - Spelling the template parameters instead (e.g., `as_pack(x, d)`) takes deduction out of the picture: each argument is passed as the type you named, enabling implicit conversions to happen at the call boundary. A reference element becomes something you ask for explicitly — `as_pack(x)` yields `pack`. Note that partial template spelling is not supported; all element types must be spelled out explicitly if template parameters are specified. @@ -455,22 +363,10 @@ auto test_pack(int x = 12, double d = 3.14) -> void ### copack: one exact alternative is present - As a payload, a `copack` models variant-like structures — i.e. a discriminated union of types. - When you evaluate a `copack` via its member `apply` function, it selects the active alternative stored inside the coproduct and passes it to your callback. Because `copack` is self-flattening, you are guaranteed that there is never a nested `copack` inside. However, a selected alternative that is itself tuple-like—a `pack`, `std::tuple`, or `std::array`—is unpacked one level into its immediate constituents, which reach your callback as separate arguments. Because normalized shapes are sums of products, one level is all they need: your callback receives the product's fields directly as function arguments. - To explicitly lift a single scalar value into a single-alternative coproduct, use `fn::as_copack(value)`. Unlike `as_pack`, it always decays: a `copack` alternative can never be a reference. When a `copack` contains **exactly one alternative**, it is singular and supports direct value extraction via the `get` utility (resolvable via ADL), which propagates references with the same semantics as `apply`. You can lift a `pack` into a `copack` (including `pack` holding references), however you cannot store `copack` inside a `pack`. There is algebraic equivalence between a hypothetical `pack` containing a `copack` (which is disallowed) and a specific shape of `copack` containing a `pack` — see section 6 for details. @@ -500,13 +396,6 @@ auto test_copack() -> void } ``` - A fundamental safety guarantee of `copack` is **exhaustive matching**. Every operation that evaluates a `copack` — mapping with `transform`, eliminating with `apply` — delegates to the same underlying multidispatch implementation. This implementation forces compile-time exhaustiveness: if your callback or overload set fails to handle even one of the possible alternatives stored in the `copack`, the compilation is rejected as ill-formed. Direct `get` extraction is disallowed for multi-alternative `copack` types for a related reason: which alternative is active is a run-time fact, so a `get` over several alternatives has no single static result type to return. Extraction has to go through dispatch, and dispatch is exhaustive. ## 5. Mapping values and errors @@ -534,13 +423,6 @@ auto mapping_values_and_errors() -> void Key principles of mapping: - - `transform` stays within the carrier: the member form never leaves its own carrier family. - Success and error states are rigidly preserved. - A bare `copack` has a member `transform`, allowing mapping across alternatives; being data rather than a carrier, it takes no pipeline verb. @@ -548,12 +430,6 @@ Key principles of mapping: - Applying an error-side operation like `transform_error` to a carrier that has no error side (like `just` or `choice`) is rejected by the compiler. - If a side is uninhabited (`copack<>`), the transformation is well-formed, but vacuous (i.e., a no-op): - `transform_error` on `expected>` is proven unreachable and a no-op. - - The member `transform` on `optional>` is proven unreachable and a no-op. > [!TIP] @@ -565,22 +441,10 @@ Key principles of mapping: > - **Identity**: $F(id_A) = id_{F(A)}$ > - **Composition**: $F(g \circ f) = F(g) \circ F(f)$ > -> -> In `libfn`, `transform` implements this morphism mapping ($fmap$). Functorial action on the initial object $0$ (the uninhabited `copack<>`) is vacuous: since there are no morphisms originating from $0$ (except the unique initial morphism), mapping over an empty alternative set is vacuously true. `libfn` leverages this by making the member `transform` on `optional>` the identity: the callback is never instantiated, let alone called. +> In `libfn`, `transform` implements this morphism mapping ($fmap$). Functorial action on the initial object $0$ (the uninhabited `copack<>`) is vacuous: since there are no morphisms originating from $0$ (except the unique initial morphism), mapping over an empty alternative set is vacuously true. `libfn` leverages this by constraint-selecting a dedicated identity overload for the member `transform` on `optional>`: the callback is neither invoked nor instantiated, making its type-correctness irrelevant. > ## 6. Product composition with operator& (conjunction) - Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results into a `pack` of the successful values over a `copack` of the exact errors either operand can produce — the conjunction of two `expected`s shown in Section 1. The runtime failure semantics are exact: @@ -590,16 +454,7 @@ The runtime failure semantics are exact: - If both operands already contain errors, the left error is retained. Because C++ operators evaluate eagerly, both operands are already fully constructed before `operator&` runs — this is an error-selection rule, not runtime short-circuiting. - Normal C++ evaluation rules apply: `operator&` does not magically make I/O lazy or parallel. - -When either operand is a `copack`, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s — a `pack` on the other side simply widens each of those `pack`s. The variadic entry point into these data rules is `fn::conjoin(...)`, which folds packs, copacks and scalars. Carriers need to be conjoined with `operator&`. Note that bare `scalar & scalar` never enters the algebra by itself: for class types it fails to compile, and for built-in types like `int` it resolves to the built-in bitwise AND. To conjoin scalars, lift them with `fn::conjoin(a, b)`, or with `fn::as_pack(a) & b` — the lift has to be the left operand, the side `operator&` dispatches on. +When either operand is a `copack`, `operator&` performs a Cartesian distribution, yielding a `copack` of `pack`s; a `pack` on the opposite side simply widens each of those `pack`s. The variadic entry point for these data-level combinations is `fn::conjoin(...)`, which folds packs, copacks, and scalars. Computations/carriers must be conjoined using `operator&`. Note that a bare `scalar & scalar` combination is not part of the algebra: for class types it fails to compile, and for built-in types like `int` it resolves to the built-in bitwise `AND`. To conjoin scalars, they must be folded with `fn::conjoin(a, b)`, or lifted using `fn::as_pack(a) & b` (the lift must be the left-hand operand, which is the side `operator&` dispatches on). ```cpp @@ -623,13 +478,7 @@ auto test_cartesian_distribution(fn::copack_for ab, fn::copack_for c When performing product composition (`operator&`), you can combine fallible carriers (like `expected` or `optional`) with any member of the **identity cluster** (detailed in Section 10): - -- **Errors are unaffected**: Because identity cluster operands can never fail, they add no alternative to the result's error channel. A `just` or `choice` operand leaves the fallible operand's error side exactly as it was, plain or graded. An `expected>` operand instead contributes its uninhabited grade to the error union — no alternative is added, but a plain error `E` is thereafter spelled as the singular `copack`. +- **Errors are unaffected**: Because identity cluster operands can never fail, they add no new alternative to the resulting error channel. A `just` or `choice` operand preserves the fallible operand's error side exactly as it was (plain or graded). An `expected>` operand instead contributes its uninhabited grade to the error union: no active alternative is added, though the resulting type's error channel is promoted to a graded copack (e.g. mapping `E` to `copack`). - **Value bundling**: The value of the identity cluster operand is conjoined with the fallible operand's value channel into a `fn::pack`. - **Unit elision**: `just` and `expected>` act as the product's identity unit and are completely elided from the value product (e.g., `expected & just` stays `expected`). - **Choice distribution**: If a `choice` operand is conjoined with a fallible carrier, the coproduct distributes through the product. This yields a `copack` of `pack`s wrapped back inside the fallible carrier. @@ -669,12 +518,6 @@ auto test_conjunction_with_identity_cluster(fn::expected ex, fn::jus > ## 7. Sum composition with operator| (disjunction) - Simultaneous sum composition combines alternative computations. By evaluating `a | b`, the leftmost operand holding a value wins: if `a` succeeded its result is preserved, otherwise `b`'s is. As with `operator&`, both operands are fully constructed before the operator runs — this is a value-selection rule, not a lazy fallback. @@ -698,12 +541,7 @@ The runtime and compile-time semantics of disjunction are exact: - `void` results enter a genuine sum as `pack<>`. If both operands are `void`, they collapse back to `void`. - **Error-side product**: - Because the overall disjunction only fails if *both* operands fail, the error channel represents the product of both errors. This is recorded positionally inside `fn::pack`. - - - If either operand's error side is a graded set (a `copack` of errors), the errors distribute through the product: $(E_1 + E_2) \times F \to (E_1 \times F) + (E_2 \times F)$. When both sides are graded, the distribution is the full Cartesian product. Either way the result is a `copack` of `pack`s, recording every combination of failure states. + - If either operand's error side is a graded set (a `copack` of errors), the errors distribute through the product: $(E_1 + E_2) \times F \to (E_1 \times F) + (E_2 \times F)$ (or the full Cartesian product when both sides are graded). In all such cases, the result is a canonical `copack` of `pack`s, recording every possible combination of failure states. - **Total Disjunction and the Identity Cluster**: - If at least one operand belongs to the **identity cluster** (detailed in Section 10), the disjunction is guaranteed to never fail at runtime. - The error side gains an uninhabited factor (`copack<>`), which collapses the error channel entirely and prevents the result from failing. @@ -743,13 +581,6 @@ auto test_disjoin(fn::expected a, fn::expected b) Sequential composition chains dependent operations where the success of one feeds the input of the next. In `libfn`, this is achieved using `and_then` (monadic *bind*). - A monadic carrier wraps a value. A *Kleisli arrow* is the callable passed to `and_then`, which takes a plain value and returns a monadic carrier of the same kind — or, from an infallible input, either an infallible carrier or a fallible kind it bridges to (Section 10). The graded pipeline of Section 1 is this *bind* chained; here is the rule on its own: @@ -770,12 +601,7 @@ auto sequential_bind() -> void The member function `.and_then` cannot perform a carrier conversion — it can only be performed with a functor operation `fn::and_then`, for the same reason member `.or_else` cannot do cross-carrier bridging (section 3). This means that a *Kleisli arrow* passed to `.and_then` has to follow a strict "same-kind" rule in regard to its return type: the "same-kind" contract defines how types interact: - An `optional` binds to an `optional`. - -- A plain `expected` binds to an `expected`, retaining its exact plain error type, or to an `expected>`, which grades it (Section 9). +- A plain `expected` binds to an `expected` (retaining its exact plain error type) or, via singular lift, to an `expected>` (which transitions it into a graded context, detailed in Section 9). - A copack-graded `expected` can union heterogeneous error sets (as demonstrated above). - A copack-valued input can join heterogeneous successful branch types into a normalized `copack`. - Exact branch convergence preserves the exact type without creating duplicate union states. @@ -808,9 +634,6 @@ static_assert(!fn::same_kind, fn::expected > $$\mu \circ M(\mu) = \mu \circ \mu_M \quad \text{and} \quad \mu \circ M(\eta) = id_M = \mu \circ \eta_M$$ > -> > In C++, `and_then` implements the *bind* operation, while `transform` implements the endofunctor *map* $M(f)$. Two of these laws are checked statically under constant evaluation in Section 14. > ## 9. Graded expected: exact error sets @@ -846,12 +669,7 @@ Two independent joins occurred during `and_then`: 1. The successful branch values formed the normalized value copack. 2. The existing outer errors (`BadSyntax`, `UnknownKey`) and the new branch errors (`OutOfRange`, `Missing`) formed the normalized error copack. - -This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` admits only the same error type `E`, or its singular lift `copack`, to participate in monadic *bind* (meaning `expected` and `expected` are **not** `same_kind`), any two **graded** `expected` types are considered `same_kind`, regardless of how their individual error sets differ (since a union of both sets can always be formed): +This seamless unioning is what allows different grades of `expected` to share the same carrier family. While standard, un-graded `expected` admits only the identical error type `E` (or its singular lift `copack`) to participate in monadic *bind* (meaning `expected` and `expected` are **not** `same_kind`), any two **graded** `expected` types are considered `same_kind` regardless of how their individual error sets differ, as the compiler can always derive their union: ```cpp @@ -872,10 +690,6 @@ To make composition more user-friendly, `libfn` allows explicit **type promotion This ensures that you can smoothly transition from a simple, un-graded computation to a graded, multi-alternative computation when entering a pipeline step that introduces alternative paths, without needing to manually wrap or lift your starting types. - If you need to perform this promotion explicitly on the carrier itself before entering a composition, `libfn` provides direct member helpers: - `.copack_error()` on `expected` explicitly lifts the error, transforming `expected` to `expected>`. @@ -902,21 +716,11 @@ auto test_explicit_lifting(fn::expected result, fn::optional> is proven unreachable and a - no-op"), so the omission here was a real gap: expected.hpp:405-415 returns *this for - an empty copack error, leaving the callback "not invoked and not even instantiated" - (or_else.hpp:195-204 routes to that arm). This is also the fact the NOTE in §10 depends on. --> -Recovery via `or_else` behaves symmetrically. It handles input error alternatives and joins any new errors produced by the recovery branches while preserving the already-successful value path. Heterogeneous recovery values require a suitable copack-valued input. Any original error handled by a branch does not automatically remain possible unless a branch explicitly returns it again. With no error alternative to handle — an `expected>` — there is nothing to recover from: the callback is never invoked, and never even instantiated. +Recovery via `or_else` behaves symmetrically. It handles input error alternatives and joins any new errors produced by the recovery branches, while preserving the successful value path. Heterogeneous recovery values require a suitable copack-valued input. Any original error handled by a branch is removed from the resulting grade unless a branch explicitly re-returns it. When there are no error alternatives to handle—such as in `expected>`—there is nothing to recover from: the callback is neither invoked nor instantiated. ### Widening is subeffecting In accordance with the subeffecting principles of graded monads (Section 1), a narrow error set can be safely widened during composition, but narrowing requires explicit mitigation. Implicit narrowing (without handling the removed errors) is unsafe and rejected by the compiler. - However, you can **safely narrow or collapse** an error grade at any point by explicitly handling and mapping the errors using `transform_error`. Because `transform_error` on a graded `expected` forces exhaustive matching over all possible alternatives, you can map multiple diverse error types into one common error type — the grade collapses to the singular `copack` of it — or into a narrower `copack`, safely reducing the static error grade of your pipeline. The bottom error grade is `copack<>`: @@ -938,9 +742,6 @@ In practice, `expected>` acts as **the graded gateway** to start > > Having established the error pomonoid $(\mathcal{E}, \cup, \emptyset, \subseteq)$ in Section 1, we can formally define `libfn`'s graded `expected` as a **lax monoidal functor** ($G : \mathcal{E} \to [\mathcal{C}, \mathcal{C}]$) from the pomonoid category $\mathcal{E}$ to the endofunctor category on C++ types (following Orchard, Wadler, and Eades, *Unifying graded and parameterised monads*). > -> > Under this formulation, the type `expected>` is what the graded structure's **monadic unit** ($\eta$) delivers at the unit object: > > $$\eta_A : A \to G_I(A) \cong \text{expected}\langle A, \text{copack}\langle\rangle\rangle$$ @@ -959,12 +760,6 @@ Consider this cross-carrier table: | `choice` | **Ts...** (A coproduct of values) | | `expected>` | **T + 0** ≅ **T** (A value and an uninhabited error) | - Each of these carriers is canonically isomorphic to its own payload: none of them adds a failure or an empty state, so a successful value is always present. Because none of them can hide an inhabited failure state, `libfn` provides a licensed pipeline operation that allows binding across these boundaries: @@ -982,24 +777,12 @@ auto test_identity_cross() -> void } ``` - The *bind* operation adopts the carrier family of the provided callback. However, the member `.and_then` remains strict to its own carrier family (for reasons explained in section 3). The pipeline-scoped functors are the licensed cross-carriers. ### Success-Path Bridging Fallible types like `expected` (with inhabited error states — `expected>` excluded) and `optional` cannot switch to infallible carriers, because doing so would risk silently discarding an inhabited (i.e. error) state. - However, identity carriers are licensed to bridge to any fallible carrier via the pipeline `fn::and_then`. Because an identity carrier is statically proven infallible, transitioning to `optional` or a standard `expected` merely introduces potential failure downstream. No pre-existing failure state is discarded, because none can exist upstream: @@ -1026,21 +809,10 @@ All three cluster members (`just`, `choice`, and `expected>`) can br Monadic operations behave naturally around this identity cluster: - - **Success mapping (`transform`)**: Remains meaningful, and the member always stays inside its own carrier family. The pipeline `fn::transform` adds one licensed crossing: a `copack` returned from a callable mapped over a `just` is promoted to the `choice` over the same alternatives (as detailed in Section 11). - **Sequential binding (`and_then`)**: Allows cross-carrier transitions *within* the identity cluster (e.g., `just` to `expected>`) when using pipeline-scoped `fn::and_then`. - **Recovery / dead-side mapping (`transform_error`, `or_else`, `recover`, `inspect_error`)**: Because `just` and `choice` have no error side, these are rejected at compile time. On `expected>`, they are vacuously well-formed but statically proven unreachable (to allow generic code on `expected` to compile). - **Short-circuiting (`fail`, `filter`)**: Strictly rejected for all identity cluster carriers, because no failure state (an inhabited error or empty state) can possibly be constructed from a never-failing identity context. - - **Elimination fallbacks (`value_or`)**: Strictly rejected on `just` and `choice` since they can never fail, rendering any fallback redundant and dead. On `expected>`, `value_or` stays well-formed so that generic code on `expected` compiles: the fallback must still be a valid initializer for `T`, but its branch is statically dead. - **Neutral observation (`inspect`, `discard`)**: Fully supported and behave normally. @@ -1104,13 +876,6 @@ Similarly, a pipeline-scoped `fn::and_then` on a `just` is permitted to return a Inside its own carrier domain, `choice` behaves differently from a bare `copack` in how it maps and binds: - - A `copack` is plain data, and it is self-flattening: a `copack` returned from a branch dissolves into the result. - A `choice` is a never-failing outer computation over those alternatives, and it is an atom: a `choice` returned from a branch survives as one alternative unless `and_then` explicitly joins it away. @@ -1134,11 +899,6 @@ auto test_choice_mapping(fn::choice ch) -> void } ``` - A callback returning a bare value belongs to `transform`, not `and_then`: `choice`'s `and_then` rejects it with a named diagnostic. > [!TIP] @@ -1152,10 +912,6 @@ A callback returning a bare value belongs to `transform`, not `and_then`: `choic > > 2. **`choice` is the monad (the "structural suspend button")**: > To restore monad laws, the monadic carrier `choice` wraps the sum in an "identity layer" to preserve structural depth: `choice>` $\ne$ `choice`. This "structural suspend button" holds eager flattening in check. -> > Thus, `choice` acts as a lawful monad under the identity endofunctor $M(A) = A$, taken over coproduct objects $A = \bigoplus_{j} T_j$: > - **Unit / return** ($\eta_A : A \to M(A)$): Canonical injection into the coproduct. > - **Join / flatten** ($\mu_A : M(M(A)) \to M(A)$): Strips one layer of the `choice` wrapper, allowing the underlying sum semantics to deduplicate variants (the codiagonal fold $[id, id]$, executed statically via `choice_for`). @@ -1164,19 +920,11 @@ A callback returning a bare value belongs to `transform`, not `and_then`: `choic > ## 12. Elimination and multidispatch - Once your computation shapes are fully derived, you may want to eliminate the structure to yield an ordinary C++ value. This is typically done via `apply` or `apply_r`. You can also use `get` on a singular `copack` (as explained in section 4); or directly read `.value()` from a `just`, where it is total. On fallible carriers `.value()` is partial: it yields the value if there is one, and otherwise throws (`bad_expected_access`, `bad_optional_access`). It is vital to distinguish `transform` from `apply`: - `transform` stays *inside* the carrier or copack, producing a new carried type. - - `apply` *eliminates* the structure entirely: the result type is deduced from the branches, which must then all yield that one same type. - `apply_r` permits branch results acceptable as the specific type `R`. @@ -1221,15 +969,10 @@ Exhaustiveness is statically constrained. If you omit a handler for a possible t ### Type-tagged elimination - Because storage shape and call shape are distinct, untagged `apply` can sometimes erase the structural context of the state (for example from `expected`). To preserve this context and prevent permissive C++ implicit conversions from accidentally conflating different states, `libfn` provides the **`apply_type`** (and `apply_type_r`) member functions. When you eliminate a carrier using `apply_type`, the active handler receives an explicit C++ state tag or constructor tag as its first argument, followed by the unpacked payload: - - On `expected`, the success arm receives `std::in_place` followed by the success value — `std::in_place` alone when the value type is `void` — while the error arm receives `fn::unexpect` followed by the error. - On `optional`, the success arm receives `std::in_place` followed by the value, while the empty arm receives `std::nullopt`. - On `copack` and `choice`, the active alternative arm receives `std::in_place_type` followed by the payload. @@ -1254,21 +997,12 @@ This is a concise reference for `libfn`'s operations, organized by channel and e - `transform`: Maps the successful value. Stays inside the carrier. - `and_then`: Sequences success-path computations. The mechanism for introducing new errors into a graded expected. - - `filter`: Enters a short-circuit state if a predicate fails. - `inspect`: Observes the successful value transparently. - `fail`: Intercepts success and forces a transition to a failure state. **Error/Empty Channel** - - `transform_error`: Maps the error value. Stays inside the carrier, and is the one operation that can narrow a graded error set (Section 9). - `or_else`: Sequences computations based on errors. Joins recovery values. - `recover`: Intercepts failure and forces a transition back to a success state. @@ -1279,9 +1013,6 @@ This is a concise reference for `libfn`'s operations, organized by channel and e - `discard`: Unconditionally evaluates the carrier, discards the result, and returns `void`. This is used to signal to the compiler that the return value is deliberately ignored. - **Elimination** - `apply`: Routes the stored state to an overload set, leaving the algebra with an ordinary C++ value. @@ -1300,23 +1031,11 @@ To reason about how these operations affect the type algebra of your computation - **`fail` and `recover` are dual symmetries**: `fail` intercepts a success-path value and forces a transition to the failure state ($Success \implies Failure$). `recover` intercepts a failure-path error and forces a transition back to the success state ($Failure \implies Success$). Neither operation widens the error set of a graded carrier. - **Graded `and_then`** is the primary mechanism for introducing a _new_ error type (widening the error grade) into your pipeline. -- **`filter` and `fail`** merely enter an _existing_ short-circuit state: the carrier must already be capable of holding the failure state. - +- **`filter` and `fail`** merely enter an *existing* short-circuit state: the carrier must already be capable of holding the failure state. - **Error-side monadic operations** (like `transform_error`, `or_else`, `recover`, and `inspect_error`) require a carrier with an error or empty side. They are rejected on `just` and `choice`, and stay vacuously well-formed on `expected>`, whose error side exists but is uninhabited (Section 10). - ## 14. Laws as C++ equalities - Where the carried types compare equal in a constant expression, the laws are checked by the compiler itself. Functor identity and monad left identity are machine-checked below; the remaining laws hold structurally, by construction of the derived types: @@ -1337,12 +1056,7 @@ constexpr auto test_laws() -> void ``` Other properties hold structurally: - + - **Functor composition**: `m | transform(f) | transform(g)` equals `m | transform([](auto v) { return g(f(v)); })`. - **Monad right identity**: `m | and_then(pure)` equals `m`. - **Monad associativity**: `(m | and_then(f)) | and_then(g)` equals `m | and_then([](auto v) { return f(v) | and_then(g); })`. For graded expected, both sides of the associativity derive the exact same normalized union grade. @@ -1351,30 +1065,19 @@ Other properties hold structurally: - **Coherent widening**: Upcasting an error through intermediate supersets yields the same final type as upcasting directly to the broadest superset. - **Identity cluster binds**: Laws hold across `just`, `choice`, and `expected>` via the canonical payload-preserving state-shape correspondence. - ## 15. C++ mechanics that preserve the algebra To make the algebraic model reliable in everyday C++, `libfn` uses extensive compiler mechanisms to reject malformed usage and preserve performance properties. ### Constraints and exhaustiveness - Public concepts and `requires` clauses enforce correctness before instantiation. Operations are protected by public applicability concepts (`fn::applicable_transform`, `fn::applicable_and_then`, …) that answer *false* for an impossible call instead of erroring deep inside template machinery. This underpins the compile-time exhaustiveness guarantees of `apply` and monadic operations established in Sections 4 and 12, catching unhandled alternatives at the boundary of instantiation. ### C++ value properties `libfn` thoroughly respects C++ value mechanics: + - Core operations are fully `constexpr`. - - The algebra's own types — `pack`, `copack`, `just` and `choice` — are structural when their elements are, so a `constexpr` value of one can be used as a template parameter. - `noexcept` is conditionally computed based on the operations provided. - Value categories (lvalue/rvalue) propagate strictly to callbacks, avoiding unnecessary copies. @@ -1385,15 +1088,6 @@ Public concepts and `requires` clauses enforce correctness before instantiation. > > ### Note — Reference Restrictions > -> > Raw reference payloads are disallowed on the carriers `expected`, `just` and `choice`, and as `copack` alternatives. `expected` stores its payload in a union, and C++ forbids a union member of reference type; the algebra's own types reject references so that dispatch granularity stays uniform per payload. `optional` is the deliberate exception — the standard specifies it, and `libfn` polyfills it. If you want to propagate references inside the other carriers, wrap them in a `pack` (e.g. `expected, E>`). @@ -1416,12 +1110,6 @@ auto test_references() -> void The library is divided into layers: - - `pfn` (Polyfill fn) is the standards-facing layer. It provides `std::optional` and `std::expected` in their C++26 shape — monadic member functions, `optional`, range support — plus smaller utilities such as `std::invoke_r` and `std::unreachable`, all available to a C++20 compiler. - `fn` is the strict extension layer. It introduces the `pack`/`copack` algebra, multidispatch, graded errors, `choice`, `just`, the pipeline verbs, and the composition operators `&` and `|`. @@ -1435,11 +1123,6 @@ For readers with a background in functional languages (like Haskell or OCaml), t | --------------- | ------------------ | | `fmap` / `map` | `transform` / `transform_error` | | `bind` / `>>=` | `and_then` | - | `pure` / `return` | `just{v}` / `expected>{v}` — a carrier constructor | | Lift / inject | `fn::as_pack` / `fn::as_copack` | | Kleisli arrow | The callable passed to `and_then` | From 1a7c6b0c9106ea31f60732e6707dd3a47a87f267 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 15:22:43 +0100 Subject: [PATCH 20/51] Clarify operator/fold applicability and symmetric duality in TYPE_ALGEBRA.md Highlight the symmetric dual nature of sum composition (disjunction) and product composition (conjunction) with reversed value/error behaviors. Clearly document that conjunction (operator&) works on both carriers and data whereas conjoin is data-only, and disjunction (operator| & disjoin) applies strictly to computation carriers. Assisted-by: Claude:gemini-1.5-pro --- TYPE_ALGEBRA.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index e6054a45..0beb11c9 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -445,7 +445,7 @@ Key principles of mapping: > ## 6. Product composition with operator& (conjunction) -Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results into a `pack` of the successful values over a `copack` of the exact errors either operand can produce — the conjunction of two `expected`s shown in Section 1. +Simultaneous product composition combines independent computations. By evaluating `a & b`, you bundle the results into a `pack` of the successful values over a `copack` of the exact errors either operand can produce — the conjunction of two `expected`s shown in Section 1. While the operator (`operator&`) applies to both monadic carriers (combining computations) and data-level types (packs, copacks, and scalars), the n-ary fold utility `fn::conjoin` is strictly for data-level types. If you pass a computation carrier to `fn::conjoin`, it will be wrapped inside a `pack` rather than conjoining the computation. The runtime failure semantics are exact: @@ -518,7 +518,9 @@ auto test_conjunction_with_identity_cluster(fn::expected ex, fn::jus > ## 7. Sum composition with operator| (disjunction) -Simultaneous sum composition combines alternative computations. By evaluating `a | b`, the leftmost operand holding a value wins: if `a` succeeded its result is preserved, otherwise `b`'s is. As with `operator&`, both operands are fully constructed before the operator runs — this is a value-selection rule, not a lazy fallback. +Simultaneous sum composition is the symmetric dual of product composition (Section 6), with the roles of value and error channels precisely reversed: conjunction (`operator&`) multiplies values (producing a product `pack`) and adds errors (producing a coproduct `copack`), while disjunction (`operator|`) adds values (producing a coproduct `copack`) and multiplies errors (producing a product `pack`). Unlike conjunction, disjunction operations (both the operator `operator|` and the n-ary fold `fn::disjoin`) apply strictly to monadic carriers. Disjunction is not defined on bare data-level types like `pack` or `copack`. + +By evaluating `a | b`, the leftmost operand holding a value wins: if `a` succeeded its result is preserved, otherwise `b`'s is. As with `operator&`, both operands are fully constructed before the operator runs — this is a value-selection rule, not a lazy fallback. ```cpp From ccde7c4e93c79c31451967bdcfe5f71af3f81815 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 19:25:10 +0100 Subject: [PATCH 21/51] Deduce the expected operand of the comparison against a value (#382) The operator constrains itself on the other operand, which is only safe where its own operand is deduced: as a hidden friend it could be spelled only as Policy::type, a non-deduced context, so deduction rejected nothing and the constraint ran for every left operand there is. Where that operand reached this same operator by ADL, satisfaction depended on itself - a hard error, where the question should simply answer false. Declared at namespace scope instead, once per carrier; its three siblings constrain on the operands' channels rather than on the other operand, and keep their form. Closes #381 Assisted-by: Claude:claude-opus-5 --- CHANGELOG.md | 4 ++++ include/fn/expected.hpp | 17 +++++++++++++++++ include/pfn/expected.hpp | 38 ++++++++++++++++++++++++++------------ tests/fn/just.cpp | 7 +++++++ tests/pfn/expected.cpp | 19 +++++++++++++++++++ 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec22252..e884d676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Design history of libfn, newest first. The living documents — [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md), [docs/](docs/) — describe only the present state of the design; when a decision makes an earlier idea obsolete, this file is where the transition is recorded and explained. +## `expected`'s comparison against a value answers instead of recursing — 25 July 2026 + +- **That comparison is no longer a hidden friend** ([#381](https://github.com/libfn/functional/issues/381)): alone among the equality operators it constrains itself on the *other* operand — `*x == v` must be valid and convertible to `bool`, an extension over [expected.object.eq]'s Mandates, so that asking answers rather than erroring inside the body. As a hidden friend of the shared storage base its own operand could only be spelled through the policy, a non-deduced context, so deduction rejected nothing and the constraint was evaluated for *every* left operand, whatever its type — constraints are checked before conversion sequences are formed. Where the right operand reached the same operator by ADL — a function pointer returning that `expected`, or any class template over one — the constraint asked the question it was answering, and both compilers reject that outright: `fn::just` could not be compared, nor even asked about. Declared at namespace scope, once per carrier, the operand is deduced and a left operand which is not that `expected` fails deduction before the constraint is reached; libstdc++ pairs the same constraint with the same deduction, for the same reason. The two are a package: the standard's hidden friend is safe precisely because its Mandates never runs during overload resolution. The three sibling operators keep their form — they constrain on the operands' channels, never on the other operand's type, so they cannot refer to themselves. + ## Carrier conversions through `or_else` and `and_then` — 24 July 2026 - **The `or_else` verb recovers across the `expected`/`optional` pair** ([#377](https://github.com/libfn/functional/issues/377)): the callback witnesses the inhabited dead state — `optional`'s empty state without arguments, `expected`'s error per alternative — and its declared carrier now names the result kind, so an `optional` pipeline recovers into `expected` and back. Self's value joins the target's value side under the same grade-sticky rules as the same-kind joins (19 July below): a `copack<>` value acquires the branch values keeping the copack spelling — `optional> | or_else(() -> expected)` is `expected, Foo>` — a plain value must be retained by every branch exactly, and the singular lift applies. The members stay strict same-kind; the verb is the licensed cross-carrier place, exactly as the cluster bind (18 July below). diff --git a/include/fn/expected.hpp b/include/fn/expected.hpp index de9990ab..e27c0f65 100644 --- a/include/fn/expected.hpp +++ b/include/fn/expected.hpp @@ -1865,6 +1865,23 @@ template class expected : private detail::_expected_ba { } }; + +// The comparison against a value, at namespace scope for the reason given where its siblings are +// declared in pfn: it is the one equality operator constrained on the OTHER operand, and that is +// safe only where this operand is deduced. +template + requires(not ::std::is_void_v && not detail::_is_some_expected) +constexpr bool operator==(expected const &x, T2 const &v) // + noexcept(noexcept(::pfn::detail::_implicit_to_bool(*x == v))) // extension + requires requires { + { *x == v } -> ::std::convertible_to; + } +{ + if (!x.has_value()) + return false; + return *x == v; +} + // Lifts for copack transformation functions [[nodiscard]] constexpr auto copack_value(some_expected_non_void auto &&src) noexcept(noexcept(FWD(src).copack_value())) -> decltype(auto) diff --git a/include/pfn/expected.hpp b/include/pfn/expected.hpp index 4399a04c..db34796e 100644 --- a/include/pfn/expected.hpp +++ b/include/pfn/expected.hpp @@ -1079,18 +1079,12 @@ template struct _expected_base { return true; return x.error() == y.error(); } - template - requires(not ::std::is_void_v && not Policy::template is_specialization) - constexpr friend bool operator==(typename Policy::template type const &x, T2 const &v) // - noexcept(noexcept(detail::_implicit_to_bool(*x == v))) // extension - requires requires { - { *x == v } -> ::std::convertible_to; - } - { - if (!x.has_value()) - return false; - return *x == v; - } + // The comparison against a value is NOT here: alone among these, its constraint asks about the + // other operand, and a hidden friend can only spell its own operand as `Policy::type` - a + // non-deduced context, which leaves deduction unable to reject anything. The constraint would then + // be evaluated for every left operand there is, and where that operand reaches this same operator + // by ADL, satisfaction depends on itself. It lives at namespace scope instead, one per carrier, so + // that its operand is deduced and a left operand which is not that carrier answers first. template constexpr friend bool operator==(typename Policy::template type const &x, unexpected const &e) // noexcept(noexcept(detail::_implicit_to_bool(x.error() == e.error()))) // extension @@ -1775,6 +1769,26 @@ template class expected : private detail::_expected_base