apsis-rs/docs/design/errors.md

184 lines
8.0 KiB
Markdown

# Errors and tolerances
English | [简体中文](./errors.zh.md)
This file records how errors are expressed across crates and how numerical
tolerances are chosen. The error enumeration of a single crate is documented
in its `error.rs`.
## Expressing an error
### A domain error
Every crate with a domain specific failure defines **its own** error type,
named and shaped consistently:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FooErrorKind {
/// Each variant names a concrete condition a caller may want to tell apart.
ReversedInterval,
DuplicateResource,
// ...
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FooError {
kind: FooErrorKind,
detail: Option<String>,
}
impl FooError {
pub fn new(kind: FooErrorKind) -> Self;
pub fn with_detail(self, detail: impl Into<String>) -> Self;
pub fn kind(&self) -> FooErrorKind;
pub fn detail(&self) -> Option<&str>;
}
impl fmt::Display for FooError { /* "kind", or "kind: detail" */ }
impl core::error::Error for FooError {}
```
The conventions:
- **An `ErrorKind` is marked `#[non_exhaustive]`**, so that adding a failure
category does not break a match downstream. All fifteen `ErrorKind`
enumerations carry the attribute.
- **An error enum that carries data is marked too**, including one whose
variants wrap a lower error, such as `apsis-orbit`'s
`EphemerisSamplingError`.
- **A state enum that is not an error is not marked.** A domain state such as
`TaskState`, `LinkState`, `TimeScale`, `HandoverDecision` or the antenna
`Pattern` gains from being matchable exhaustively inside a single
repository: when a variant is added, the compiler points at every `match`
that needs updating. The reasoning is the opposite of an error's, because
one more error category should let a caller fall through to a default
branch, while one more state usually has to be handled explicitly. The test
is whether a caller will `match` the enum and take a default on some
branches: if so, mark it; if not, leave it.
- **`kind` is matched, `detail` is read.** A caller matches on `kind` to
write recovery logic and logs `detail`. Because `detail` takes no part in
matching, its wording may change freely.
- **`Display` prints the kind**, followed by `": detail"` when there is one,
so a log line always begins with the category.
- **`core::error::Error` is implemented**, so an error can nest as the
`source` of another. All fifteen error types implement it.
- **A bare `String` or `Box<dyn Error>` is never a public error.** The first
cannot be matched and the second loses the category. A private helper may
pass a `String` as a reason internally, but the public boundary is an enum.
The `precedence_floor` in `apsis-scheduler` is an instance: its private
`String` ends up inside the public `TaskState::Infeasible { reason }`.
### The general error
`apsis-core::Error` and `ErrorKind` are a general purpose fallback carrying
a coarse category such as `InvalidInput` or `NotFound`. They are **not** the
error surface of the layers: no layer uses them, because a coarse category
loses the distinction a caller needs. `InvalidInput` cannot tell a reversed
interval from a duplicate identifier.
They are kept for two reasons: to give a function whose failure has no
interesting structure a ready return type, and to give the workspace one
extensible fallback shape. New code whose failure has a domain meaning should
define a domain error.
### Failure against the absence of a value
- **`Option`** means that having no value is an ordinary outcome rather than
a failure, such as an instant outside the covered interval, which
`Ephemeris::state_at` reports as `None`, or a tangent that is undefined at
an angle.
- **`Result`** means an operation failed for a diagnosable reason, such as a
reversed interval, a duplicate resource, or a value outside the domain the
model covers.
The test is whether the caller needs to know **why**. If it only needs to
know that there is nothing, use `Option`; if it needs to tell the failures
apart and recover, use `Result`.
## Numerical tolerances
### Convergence of an iteration
An iterative solve, whether of Kepler's equation, a nutation series or a ray
trace, is governed by a private constant inside its crate:
| Crate | Constant | Value | Meaning |
| --- | --- | --- | --- |
| `apsis-kepler` | `TOLERANCE` | `1e-14` | the step of the Newton iteration for the eccentric anomaly |
| `apsis-sgp4` | `KEPLER_TOLERANCE` | `1e-12` | the residual of the Kepler equation iteration |
These values trade floating point precision against iteration count: they sit
close to the machine precision of an `f64` while still guaranteeing that the
iteration terminates, each with its own `MAX_ITERATIONS` bound.
### Tolerances of the cross-checks
A cross-check sets its tolerance by the **physical quantity** rather than by
one number for everything:
| Quantity | Tolerance |
| --- | --- |
| Position | < 1e-3 km |
| Velocity | < 1e-6 km/s |
| Time | < 1e-6 s |
| Coverage angle | 1e-12 rad |
| Coverage area and fraction | 1e-12 relative |
A tolerance sits far above the disagreement actually observed, which is
usually at the floating point level, and far below the accuracy of the model
itself. That way it catches a mistyped radius or a missing unit conversion
without reporting on the last bit of a floating point value. The table in
`validation/README.zh.md` lists them per script.
### Comparing exactly
A tolerance should be avoided where it is not needed. An integer index, a
count or a flag is compared for equality. An angle reduction such as
`wrap_to_pi` uses a half-open interval at the boundary rather than an
epsilon. A floating point comparison is for a quantity that is genuinely a
continuous approximation.
## expect in library code
The workspace convention is that library code uses no `unwrap`, `expect` or
`panic!`. The **intent** of the rule is that they must not handle a failure
on external input, which has to become a `Result` or an `Option` the caller
can act on.
Allowing no `expect` at all would push the code towards `unwrap`, which is
worse, or into long hand written branches. So one case is permitted:
**an `expect` on an internal invariant that a check has already guarded.**
Such an assertion does not turn untrusted data into a panic; it turns "if
this were empty it would be a bug in this crate" into a readable failure
message.
The test is whether a guard making the branch unreachable sits on the line
above, or whether the value was just built by this function. All thirteen
current sites satisfy it:
| Site | Invariant | Guard |
| --- | --- | --- |
| `apsis-math/src/parallel.rs` | every slot was filled by one thread | the loop assigns each index exactly once |
| `apsis-schedule/src/schedule.rs` | the placement just pushed exists | the line above |
| `apsis-scheduler/src/improve.rs` | the value whose `is_none()` was just checked is not none | the `is_none()` branch above |
| `apsis-scheduler/src/planner.rs` | an identifier in the order is in the task list | the order is built from the task list |
| `apsis-topology/src/series.rs` | the time is finite | validated on construction |
| `apsis-handover/src/decision.rs` | the candidate list is not empty | the `validate` call above |
| `apsis-constellation/src/walker.rs` | the plane count and the satellites per plane are positive | the divisor is validated before |
| `apsis-kepler/src/elements.rs` | the node, the radius and the angular momentum are non-zero | the `norm() == 0.0` branch just above |
| `apsis-event/src/queue.rs` | a popped entry has its payload | guaranteed by the construction order of the queue |
Removing a guard means the matching `expect` must become a `Result`; the two
change together.
## Checks
- Each crate's tests cover every `kind` of its error, including the path
that constructs it and the path that recovers from it.
- A cross-check reports through its exit code: zero to pass, one to exceed
the tolerance, two for a missing input.
- `cargo clippy -- -D warnings` keeps an `unwrap` or `expect` from reaching
library code unnoticed.