Type Classes as Logic Programs

Type class sys­tems are logic lan­guages. A spe­cific set of trait def­i­n­i­tions and imple­men­tors form a logic pro­gram. Trait res­o­lu­tion then eval­u­ates the con­structed pro­gram and is imple­mented as a back­track­ing search. We reduce trait res­o­lu­tion to logic pro­gram­ming to empha­size prob­lem struc­ture. This prob­lem is not inher­ent to Rust per say, but a broader com­plex­ity com­ing from this eval­u­a­tion style. Logic lan­guages pro­vide a lan­guage-agnos­tic way to dis­cuss type class sys­tems. By abstract­ing away Rust-spe­cific details we high­light that this prob­lem is inher­ent to any type class imple­men­ta­tion reducible to logic pro­gram eval­u­a­tion.

To explore the dif­fer­ence between the good and poor diag­nos­tics of the pre­vi­ous sec­tion we will turn each set of traits and imple­men­ta­tions into a logic pro­gram. We then inter­pret a query over this pro­gram to sim­u­late trait res­o­lu­tion.

Our toy exam­ple moti­vat­ing the need for type class abstrac­tion, the ToString trait, pro­duced a good diag­nos­tic. Let us step through the struc­ture of this pro­gram one more time, but instead we will focus on the declar­a­tive seman­tics of the trait lan­guage.

trait ToString
{ /*...*/ }

impl ToString for i32
{ /*...*/ }

impl ToString for char
{ /*...*/ }

impl<T> ToString for Vec<T>
where
  T: ToString,
{ /*...*/ }
to_string(i32).

to_string(char).

to_string(vec(T)) :-
  to_string(T).

A trait def­i­n­i­tion estab­lishes a unit of shared bahav­ior. We can ask ques­tions like “Does this type imple­ment that trait?” And thus is akin to a sin­gle-arity Pro­log pred­i­cate. Using Pro­log con­ven­tions we have the to_string/1 pred­i­cate that declares its para­me­ter an imple­men­tor of the ToString trait.We can pro­vide a trait imple­men­ta­tion for ground types. Shown in are the imple­men­ta­tions for i32 and char. These imple­men­ta­tions declare facts. A fact that these types sat­isfy the to_string pred­i­cate.With poly­mor­phism we can chain trait imple­men­ta­tions, such as the type-para­me­ter­ized imple­men­ta­tion for Vec<T>. These imple­men­ta­tions form a rule. In order for the rule to be proven, sev­eral addi­tional body pred­i­cates need to be sat­is­fied. These pred­i­cates are the clauses that come after Rust’s where key­word, or after Pro­log’s :- in the rule body.Cal­lling our print_ln func­tion with a vec­tor of float­ing-point num­bers cre­ated a trait bound, Vec<f32>: ToString. Non-para­me­ter­ized types are con­verted to atoms in the logic model. It is then the com­piler’s job to resolve the ToString imple­men­ta­tion for the instan­ti­ated type T. In logic pro­gram­ming we can phrase this bound as a query, ?- to_string(vec(f32)), inter­pret­ing this query is equiv­a­lent to to resolv­ing the trait imple­men­ta­tion. The funny ?- indi­cates that the clause is a ques­tion, not a state­ment.

We take the def­i­n­i­tion of logic pro­gram com­pu­ta­tion from Ster­ling and Shapiro1994: com­pu­ta­tion pro­gresses via goal reduc­tion. At each stage there is a con­junc­tion of goals to be proved, this is called the resol­vent. The inter­preter will choose a goal from the resol­vent and a clause from the pro­gram envi­ron­ment such that the clause head uni­fies with the goal. (A clause is either a fact or a rule.) The com­pu­ta­tion con­tin­ues by replac­ing the resol­vent with the clause’s body. When pick­ing a goal and clause, we con­strain the inter­preter to choose left-to-right and top-to-bot­tom, respec­tively. After suc­cess­fully prov­ing a goal in the con­junc­tion the inter­preter con­tin­ues to the next. After fail­ing to prove a goal the inter­preter back­tracks to the last point of choice, where a clause was picked from the envi­ron­ment, and chooses the next clause if one is avail­able. Trac­ing the exe­cu­tion of the described inter­preter con­structs a proof tree. Each goal has a set of can­di­date clauses from which the inter­preter can choose to per­form goal reduc­tion. Reduc­ing a goal with a rule clause intro­duces a con­junc­tion of sub­goals that must be proven. If a goal fails, the inter­preter can back­track and retry by reduc­ing the goal with a dif­fer­ent clause. This was quite a bit of tech­ni­cal infor­ma­tion, but the visu­al­iza­tion clar­i­fies the proof tree struc­ture.

Trac­ing the exe­cu­tion of the sim­ple pro­gram using ToString, we get the proof tree shown in , goals con­nected by a dashed line rep­re­sent an Or rela­tion­ship. At least one child in an Or rela­tion­ship must hold for the par­ent to be proven. Goals con­nected with a solid line form an And rela­tion­ship. All child goals must be proven for their par­ent to be proven. This type of proof tree is com­monly referred to as an And–Or tree. Each level in the tree is either a con­junc­tion: all child goals must be proven, or a dis­junc­tion: at least one child goal must be proven.

to_string(i32);
to_string(char);

to_string(vec(T)) :-
  to_string(T);

?- to_string(vec(f32)).
flowchart TD
  classDef invisible display:none
  C["to_string(f32)"]
  B["to_string(vec(f32)) :- to_string(f32)"]
  A["?- to_string(vec(f32))"]
  D["No"]
  C ~~~ D
  A ~~~ B
  B ~~~ C
  class D,B,C invisible

The exe­cu­tion of this pro­gram was as fol­lows. The start of the search process is the root goal. This is where the Rust trait solver asks “Does Vec<f32> imple­ment ToString?”

to_string(i32);
to_string(char);

to_string(vec(T)) :-
    to_string(T);

?- to_string(vec(f32)).
flowchart TD
  classDef invisible display:none
  C["to_string(f32)"]
  B["to_string(vec(f32)) :- to_string(f32)"]
  A["?- to_string(vec(f32))"]
  D["No"]
  C ~~~ D
  A -.-> B
  B --> C
  class D invisible

The inter­preter uni­fies the goal with the rule head from the pro­gram. Uni­fy­ing to_string(vec(T)) = to_string(vec(f32)) results in the con­straint T = f32. The cur­rent goal is reduced to the body of the rule, to_string(f32). In this case there is only one goal of the rule body, if there were many, they would be con­junc­tive. These con­junc­tive nodes are sib­lings in the proof tree and they form an And rela­tion­ship with their par­ent. This means that for the par­ent goal to be proven, all chil­dren must be proven.

to_string(i32);
to_string(char);

to_string(vec(T)) :-
  to_string(T);

?- to_string(vec(f32)).
flowchart TD
  classDef invisible display:none
  C["to_string(f32)"]
  B["to_string(vec(f32)) :- to_string(f32)"]
  A["?- to_string(vec(f32))"]
  D["No"]
  C --> D
  A -.-> B
  B --> C

The now cur­rent goal, to_string(f32), fails to unify with any clause in the pro­gram. There­fore this goal can­not be proven. There are no other rules the inter­preter can back­track to. There­fore, the root goal, to_string(vec(f32)) can­not be proven. It’s then said that the trait bound Vec<f32>: ToString is not sat­is­fied.

Now that ter­mi­nol­ogy is out of the way and we’ve seen a sim­ple exam­ple, it’s time to start look­ing at the proof tree for the Axum trait error. The result­ing proof tree for the sim­ple ToString trait was more of a proof stick, no back­track­ing occurred. Let us direct our atten­tion to the run­ning exam­ple with Axum, intro­duced in , where we will see some branch­ing.

Type-checking a Web Server

fn_once([], _).
fn_once([_|_], _).

from_request_parts(parts(_)).
from_request(bytes).
from_request(string).

future(promise(O), O).
into_response(string).

handler(fn_once([T1], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request(T1).

handler(fn_once([T1, T2], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request_parts(T1),
    from_request(T2).

?- handler(fn_once([string, bytes]), promise(string)).
flowchart LR
  classDef invisible display:none
  C["No"]
  B["handler(fn_once([T1], Fut)) :- ..."]
  A["?- handler(<br/>  fn_once([string, bytes]),<br/>  promise(string)<br/>)."]
  G["from_request_parts(string)"]
  F["into_response(string)"]
  E["future(promise(string), string)"]
  D["handler(fn_once([T1, T2], Fut)) :- ..."]
  K["Yes"]
  J["Yes"]
  I["from_request(bytes)"]
  H["No"]
  D ~~~ G
  D ~~~ E
  A ~~~ B
  E ~~~ J
  G ~~~ H
  D ~~~ F
  A ~~~ D
  D ~~~ I
  B ~~~ C
  F ~~~ K
  class H,I,J,K,D,E,F,G,B,C invisible

We start with the root goal, ask­ing whether the type login imple­ments Handler. In Rust, login has typeThe logic ver­sion of the Axum API uses the atom promise to rep­re­sent some­thing that imple­ments the future trait. This is a bit over-sim­pli­fied from the Rust Async model, but an unim­por­tant detail for this exam­ple.

fn(String, Bytes) -> impl Future<Output = String>

fn_once([], _).
fn_once([_|_], _).

from_request_parts(parts(_)).
from_request(bytes).
from_request(string).

future(promise(O), O).
into_response(string).

handler(fn_once([T1], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request(T1).

handler(fn_once([T1, T2], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request_parts(T1),
    from_request(T2).

?- handler(fn_once([string, bytes]), promise(string)).
flowchart LR
  classDef invisible display:none
  C["No"]
  B["handler(fn_once([T1], Fut)) :- ..."]
  A["?- handler(<br/>  fn_once([string, bytes]),<br/>  promise(string)<br/>)."]
  G["from_request_parts(string)"]
  F["into_response(string)"]
  E["future(promise(string), string)"]
  D["handler(fn_once([T1, T2], Fut)) :- ..."]
  K["Yes"]
  J["Yes"]
  I["from_request(bytes)"]
  H["No"]
  D ~~~ G
  D ~~~ E
  A -.-> B
  E ~~~ J
  G ~~~ H
  D ~~~ F
  A ~~~ D
  D ~~~ I
  B --> C
  F ~~~ K
  class H,I,J,K,D,E,F,G invisible

Our inter­preter chooses clauses top-to-bot­tom. The first rule handler(fn_once([T1], Fut)) is tried but auto­mat­i­cally fails. We’ve said that the rule head needs to unify with the goal and in this case the arity of the goal and rule head func­tions dif­fer—two vs one.

fn_once([], _).
fn_once([_|_], _).

from_request_parts(parts(_)).
from_request(bytes).
from_request(string).

future(promise(O), O).
into_response(string).

handler(fn_once([T1], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request(T1).

handler(fn_once([T1, T2], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request_parts(T1),
    from_request(T2).

?- handler(fn_once([string, bytes]), promise(string)).
flowchart LR
  classDef invisible display:none
  C["No"]
  B["handler(fn_once([T1], Fut)) :- ..."]
  A["?- handler(<br/>  fn_once([string, bytes]),<br/>  promise(string)<br/>)."]
  G["from_request_parts(string)"]
  F["into_response(string)"]
  E["future(promise(string), string)"]
  D["handler(fn_once([T1, T2], Fut)) :- ..."]
  K["Yes"]
  J["Yes"]
  I["from_request(bytes)"]
  H["No"]
  D --> G
  D --> E
  A -.-> B
  E ~~~ J
  G ~~~ H
  D --> F
  A -.-> D
  D --> I
  B --> C
  F ~~~ K
  class H,J,K invisible

Con­tin­u­ing its search the inter­preter back­tracks and tries to reduce the goal via a dif­fer­ent clause, the sec­ond imple­men­ta­tion rule for handler. The sec­ond clause head does unify with the cur­rent goal. Take note that these two rules form an Or rela­tion­ship with the root goal. The first failed but the root goal may still be proven via the sec­ond clause. After the clause head uni­fied, the goal is replaced by the clause body. The sub­goals intro­duced in the reduc­tion are a con­junc­tion, form­ing an And rela­tion­ship with the par­ent rule.

fn_once([], _).
fn_once([_|_], _).

from_request_parts(parts(_)).
from_request(bytes).
from_request(string).

future(promise(O), O).
into_response(string).

handler(fn_once([T1], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request(T1).

handler(fn_once([T1, T2], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request_parts(T1),
    from_request(T2).

?- handler(fn_once([string, bytes]), promise(string)).
flowchart LR
  classDef invisible display:none
  C["No"]
  B["handler(fn_once([T1], Fut)) :- ..."]
  A["?- handler(<br/>  fn_once([string, bytes]),<br/>  promise(string)<br/>)."]
  G["from_request_parts(string)"]
  F["into_response(string)"]
  E["future(promise(string), string)"]
  D["handler(fn_once([T1, T2], Fut)) :- ..."]
  K["Yes"]
  J["Yes"]
  I["from_request(bytes)"]
  H["No"]
  D --> G
  D --> E
  A -.-> B
  E --> J
  G ~~~ H
  D --> F
  A -.-> D
  D --> I
  B --> C
  F ~~~ K
  class H,K invisible

Work­ing left-to-right, the goal future(promise(string), Res) needs to be proven as part of the con­junc­tion. The inter­preter uni­fies this with a pro­gram fact and the sub­goal is said to be proven. The effect of this uni­fi­ca­tion is the learned con­straint, Res = string.

fn_once([], _).
fn_once([_|_], _).

from_request_parts(parts(_)).
from_request(bytes).
from_request(string).

future(promise(O), O).
into_response(string).

handler(fn_once([T1], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request(T1).

handler(fn_once([T1, T2], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request_parts(T1),
    from_request(T2).

?- handler(fn_once([string, bytes]), promise(string)).
flowchart LR
  classDef invisible display:none
  C["No"]
  B["handler(fn_once([T1], Fut)) :- ..."]
  A["?- handler(<br/>  fn_once([string, bytes]),<br/>  promise(string)<br/>)."]
  G["from_request_parts(string)"]
  F["into_response(string)"]
  E["future(promise(string), string)"]
  D["handler(fn_once([T1, T2], Fut)) :- ..."]
  K["Yes"]
  J["Yes"]
  I["from_request(bytes)"]
  H["No"]
  D --> G
  D --> E
  A -.-> B
  E --> J
  G ~~~ H
  D --> F
  A -.-> D
  D --> I
  B --> C
  F --> K
  class H invisible

Con­tin­u­ing in the inter­pre­ta­tion we now have to prove the sub­goal into_response(string). One again a fact from the pro­gram uni­fies with the goal and it is said to be proven. IntoResponse is the set of types the Axum API knows how to con­vert into an HTTP response.

fn_once([], _).
fn_once([_|_], _).

from_request_parts(parts(_)).
from_request(bytes).
from_request(string).

future(promise(O), O).
into_response(string).

handler(fn_once([T1], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request(T1).

handler(fn_once([T1, T2], Fut)) :-
    future(Fut, Res),
    into_response(Res),
    from_request_parts(T1),
    from_request(T2).

?- handler(fn_once([string, bytes]), promise(string)).
flowchart LR
  classDef invisible display:none
  C["No"]
  B["handler(fn_once([T1], Fut)) :- ..."]
  A["?- handler(<br/>  fn_once([string, bytes]),<br/>  promise(string)<br/>)."]
  G["from_request_parts(string)"]
  F["into_response(string)"]
  E["future(promise(string), string)"]
  D["handler(fn_once([T1, T2], Fut)) :- ..."]
  K["Yes"]
  J["Yes"]
  I["from_request(bytes)"]
  H["No"]
  D --> G
  D --> E
  A -.-> B
  E --> J
  G --> H
  D --> F
  A -.-> D
  D --> I
  B --> C
  F --> K

The first para­me­ter type of the func­tion login, String must imple­ment FromRequestParts. The goal, from_request_parts(string), does not unify with any pro­gram clause. There­fore, the goal fails. At this point, the inter­preter has exhausted the dis­junc­tion of clause rules and can no longer back­track. The root goal can there­fore also not be proven and exe­cu­tion halts.

Diagnosing Errors

Each of the run­ning exam­ple pro­grams was trans­lated into an equiv­a­lent logic pro­gram, and the queries were traced to build proof trees. Both trees in and failed, and the com­piler must turn this proof tree into a diag­nos­tic mes­sage. We can par­tially for­mal­ize this final step in the com­piler as a func­tion, diagnose, that takes a tree as input and returns the prob­lem­atic tree node.

diagnose :: ProofTree -> Node

The diagnose func­tion is reduc­tive. It takes the large data of a proof tree and chooses a small piece to show devel­op­ers. There is a ten­sion in form­ing error diag­no­sis as a reduc­tion. Diag­nos­tics want to be as help­ful as pos­si­ble, reflect on our cri­te­ria for a good diag­nos­tic: it must deter­mine the spe­cific root cause and includes full prove­nance of the fail­ure. Shown in are the two fail­ing proof trees for the good and poor diag­nos­tics.


The ToString exam­ples pro­duces a sim­ple tree. There only exists a sin­gle fail­ing node! In this case the reduc­tion is straight­for­ward, by reduc­ing the tree to its sin­gle fail­ing node, to_string(f32), no infor­ma­tion has been lost. The spe­cific root cause is the fail­ing node and the prove­nance is gen­er­ated by tra­vers­ing the tree upwards, from fail­ing node to root. The com­plex tree pro­duced by the Axum Handler exam­ple is not as straight­for­ward. Here the reduc­tion must choose between two fail­ing nodes, how­ever, choos­ing either of the nodes for­gets the infor­ma­tion of the other failed branch. Com­piler diag­nos­tics are con­ser­v­a­tive, so rather than reduce the tree to a failed node the tree is reduced to the least com­mon ances­tor of the failed nodes.

One’s ini­tial intu­ition may be to sim­ply improve reduc­tion algo­rithm. This is equiv­a­lent to “mak­ing diag­nos­tics bet­ter.” Indeed there are cir­cum­stances where type infor­ma­tion may be able to elim­i­nate cer­tain failed branches, but the prob­lem is still that of reduc­tion.

This the­sis pro­poses to change the fun­da­men­tal prob­lem of error diag­no­sis. We pro­poses an alter­na­tive func­tion, ui, to the reduc­tive diagnose:The type con­struc­tor Visualize is doing a lot of heavy lift­ing in this sig­na­ture.

ui :: ProofTree -> Visualize ProofTree

No longer is error diag­no­sis a reduc­tion prob­lem. By chang­ing the out­put type from Node to Visualize ProofTree, the prob­lem is now an inter­face prob­lem. We want to facil­i­tate infor­ma­tion extrac­tion from the proof tree and allow devel­op­ers to dis­cover the root cause of an error, while pre­serv­ing prove­nance. The ben­e­fit is that no infor­ma­tion is lost; the root cause of an error is always avail­able. The draw­back, again, is that no infor­ma­tion is lost. Proof trees are large and com­plex, and the designed inter­face should facil­i­tate local­iz­ing the root cause with as few inter­ac­tions as pos­si­ble.