Weeknotes May 2026 weeks 18-19
Over the past two weeks I've been mainly wrestling with odoc.
I made a release of odoc - 3.2.0 - and a PR to opam-repository. In parallel, I also just managed to make an ocaml-docs-ci job that tracked the master branch of odoc. I was pretty horrified therefore to see a glaring error in the docs build of base.
It turned out that one of the patches we made for OCaml 5.5.0, to support modular explicits, had an issue. It caught me by surprise because I didn't think that anything already existed that uses this, but in fact the new constructors in the AST are being used by pre-existing language constructs, so even without source changes we were exercising the new logic. This turned out to be quite useful as it showed that there was some missing transformations in the code causing a hard failure. Fortunately the fix was fairly straightforward.
With this success of the new docs ci, I thought we could do some more serious runs, so I set a job going building docs for the latest version of everything in opam using the master branch of odoc. After getting this running the following few days was spent sorting out the issues that came up - ocaml/odoc#1429 and ocaml/odoc#1431.
The second here showed up a problem in the fix for ocaml/odoc#930. In that, as I wrote about previously, the fix was to rewrite module types being included without inline module substitutions. The code that did that effectively rewrote the following:
sig
...
module Foo := Bar
...
endas
sig
module Foo = Bar
end with module Foo := BarThe problem that the docs ci found was an instance that looked more like this:
sig
module A
...
module Foo := A
...
endand of course, in this, we can't simply do the above transformation as module A is only defined within the scope of the signature, so we can't reference it from outside.
A few months back I had rewritten this code so that rather than going through the Fragmap functions it directly used a substitution, but I hadn't quite completed this. It seemed a good opportunity to dig it out and give it a whirl, resulting in a nice patch. With the power of the new docs CI I set this going, and while it fixed that issue, it exposed another. At this point I felt that it was getting a bit hairy for a last-minute fix, as we needed to release odoc rather quickly for OCaml 5.5 support. So I shelved that plan, and instead went for the rather less dangerous fallback of doing nothing when we hit that pattern. The thought was that issue ocaml/odoc#930 is relatively rare, but the fragmap fix is applied to all includes. The 'local substitution' pattern is also rare, as it was only a few packages that showed the error when I did the full docs run. So the chances of hitting both we can expect to be even rarer! We went with this solution in the end.
The other small defect I found was an interesting one. There is an open issue with odoc related to building docs for colibri2. What I happened to notice with the docs ci build was that the docs build was succeeding. After a bit of debugging I finally figured out that this was due to missing dependency libraries during the build. Without these, it failed to do some expansions, which meant that it didn't hit the problematic case. This led to a PR, and colibri2 is back to failing.
Thanks to panglesd's quick review, these were merged, and we were finally able to release 3.2.1 and put it into opam.
Docs CI and universes
Docs CI has always had this concept of consistent "universes" of packages which are layered together. As a simplified example, imagine one universe for each version of the OCaml compiler. Then there are several versions of findlib, each of which can compile with each version of ocaml, so now we have n*m possible universes. Obviously this would lead to a combinatorial explosion if we wanted to have every possible universe, but in practise there's a huge amount of overlap, even when we're trying to build every version of every package. So we exploit this by having a triple that defines every build - the package name, the package version, and a hash representing the dependencies of the package.
A core principle of these universes is that the docs produced are also consistent. For example, if your package was built with OCaml 5.4.1, no matter where you click, you'll never end up on a package that was build with a different version of OCaml. Similarly for every other dependency of the package whose docs you are browsing. You will always stay within the dependency universe.
When we introduced "forward links" in odoc 3.0, we retained this. These forward links are links from a package's docs that should go to a reverse dependency of the package. For example, odoc's docs link to odoc-driver and odig, as examples of tools that use odoc. This is a straightforward extension of docs generation if all involved packages are already installed, as odoc has had split compile and link phases since very early versions. Compilation must be done in strict dependency order, as when odoc processes a cmt(i) files it requires the odoc files of dependencies. In contrast, linking only looks at odoc files, so once we've computed all of these we can link in any order we wish.
The approach taken in ocaml-docs-ci is different. Because we want to avoid processing packages more than once, we process each package individually. If a package has no forward links, we can do both compilation and linking for that package in one go. If package "a" has forward links to package "b" that depends upon "a", we have to:
1. compile the odoc files for package "a", 2. compile the odoc files for package "b", ensuring that the odoc files of package "a" are available 3. link the odoc files for package "a", ensuring that the odoc files of package "b" are available 4. link the odoc files for package "b", ensuring that the odoc files of package "a" are available.
This is obviously more complex than if there were no forward links, as then it would be just a two step process - compile and link "a", then compile and link "b". The only complixity is in ensuring that the odoc files for "a" are available when doing the compile and link for "b".
A further wrinkle is that your forward links might require packages with version constraints. For example, odoc might have links to documented features in odoc-driver that are only in recent versions. Even worse: there might be multiple versions of "b" that all use the same version of "a", and here we hit against the principle outlined above; that no matter where you click you never leave your universe. If there are multiple versions of "b" that link to the same version of "a", then when you click the reverse links, you must end up back on the same "b" you started with. That means there must be multiple different versions of the docs for "a", even if they're all built with the same set of build dependencies!
So if we're being as efficient as possible, we need to divorce the build universe from the docs universe. That way we only need to build the package "a" once, even if we build docs for it multiple times. Currently day11 doesn't do this, and is inefficiently doing multiple redundant builds for some packages. This doens't affect the output as it's the docs universes that are important there, but it does mean it's wasting resources.
oi docs
All of which brings me on to oi. I had a very useful discussion with Anil on whether we need to bring all of this complexity into oi to build docs there. When you're working on a package in oi, whilst it manages many different universes, it will only instantiate one for your package. Therefore we don't need to worry so much about staying in the same universe - there can be only one.
So he suggested doing late binding of the forward links somehow, to enable building docs alongside the standard package build. The forward links would be unresolved, but we could arrange for the link to be resolved once you installed the package to be linked to. This could be done in a variety of ways. One thought I had was that I could use a branch I'd been working on a while ago which simply enumerates the possible link targets in an odocl file. The thing that makes this achievable is that we're only talking about cross-package references, so a lot of machinery that exists within odoc is unnecessary. We'll only allow canonical reference, and scoping isn't a problem as they will all be fully qualified references.
I'll be working on making this work for the rest of this week.
Random ideas
Some random ideas I had:
Package API diff
It's always been a nice idea to use the data we generate in docs CI in more creative ways than just producing the docs. A fairly obvious idea is to generate diffs to show what's changed in between versions of the packages. However, this was always a bit problematic because your own package doesn't necessarily uniquely define your API signatures. This is, of course, the whole reason we produce multiple universes of docs. However, in odoc 3.1 we added a neat feature that was intended to help you by only showing errors when they exist in your own packages, which worked by keeping track on a signature level of the package from which the signature came. What I realised is that we can use this info to discard diffs between package version builds so that they only show what's changed in the package being queried, rather than differences due to your dependencies.
A quick example. Odoc has several maps such as Component.ModuleMap that are constructed from the standard library functor, and therefore the elements in them you can see at that link have various 'since' markers - but those all refer to the OCaml version, not the odoc version, even though these are odoc's docs. If we're trying to find the difference between two versions of odoc, we might observe differences in these just because we happened to compile the two odoc's with different compilers. Using the info put in for the warnings would eliminate this.
Fuzzing odoc-parser
We ought to fuzz odoc-parser. There are some nice tests in there - mostly expect tests - but a proper comprehensive fuzz will definitely show up some issues, as it's a hand-rolled parser. We spent some time a while back building a menhir parser, but the error paths were very hard to get working as well as the current implementation handles them. Fuzzing could help strengthen the case for using the menhir parser.
Thomas has a neat fuzzing skill for Claude that he's been using to great effect with his protocol development work, so I'd quite like to take a look into using that to give that a whirl.