Integrate engine into application prototype #15
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "engine-integration"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Port the engine prototype to Rust, integrate it into the application prototype, and use it to enforce the constraints.
Features
To see the engine in action:
The checkbox at the left end of a constraint outline item controls whether the constraint is active. Activating a constraint triggers a solution update. (Deactivating a constraint doesn't, since the remaining active constraints are still satisfied.)
Precision
The Julia prototype of the engine uses a generic scalar type, so you can pass in any type the linear algebra functions are implemented for. The examples use the adjustable-precision
BigFloat
type.In the Rust port of the engine, the scalar type is currently fixed at
f64
. Switching to generic scalars shouldn't be too hard, but I haven't looked into which other types the linear algebra functions are implemented for.Testing
To confirm quantitatively that the Rust port of the engine is working, you can go to the
app-proto
folder and:Run some automated tests by calling
cargo test
.Inspect the optimization process in a few examples calling the
run-examples
script. The first example that prints is the same as the Irisawa hexlet example from the engine prototype. If you go intoengine-proto/gram-test
, launch Julia, and thenyou should see that it prints basically the same loss history until the last few steps, when the lower default precision of the Rust engine really starts to show.
A small engine revision
The Rust port of the engine improves on the Julia prototype in one part of the constraint-solving routine: projecting the Hessian onto the subspace where the frozen entries stay constant. The Julia prototype does this by removing the rows and columns of the Hessian that correspond to the frozen entries, finding the Newton step from the resulting "compressed" Hessian, and then adding zero entries to the Newton step in the appropriate places. The Rust port instead replaces each frozen row and column with its corresponding standard unit vector, avoiding the finicky compressing and decompressing steps.
To confirm that this version of the constraint-solving routine works the same as the original, I implemented it in Julia as
realize_gram_alt_proj
. The solutions we get from this routine match the ones we get from the originalrealize_gram
to very high precision, and in the simplest examples (sphere-in-tetrahedron.jl
andtetrahedron-radius-ratio.jl
), the descent paths also match to very high precision. In a more complicated example (irisawa-hexlet.jl
), the descent paths diverge about a quarter of the way into the search, even though they end up in the same place.For me, the Julia file does not print a loss history, only the final loss, which is indeed far smaller than the loss achieved by the Rust version. But since this is ancillary to this PR, I am certainly not going to hold up merging it for this sake.
Please fix the warning and update this PR; we never want our code to be in a state in which it generates warnings. In fact, if there is a compiler flag to make warnings into errors, please turn it on. Thanks!
I know we discussed this in person, but the fact that the Julia solution paths diverged when you made this change was a little surprising and worrisome to me. What's the mathematical difference between the two approaches? Are they theoretically identical, with differences only occurring for numerical reasons? Is it the case that you don't get exact 0s in the resulting Newton step with the adjusted approach in Rust, only approximate 0s? If the latter is the case, and in fact exact 0s in the step are "better" because after all, that coordinate is clamped to a specific value and should not change at all, and if it's just programming inconvenience to go back to the original Julia method, then let's do so. I assume any time lost in doing the matrix reconfiguration is gained back in dealing with a Hessian with fewer rows/columns...
OK, went through the code. Since this prototype seems like it has a chance to become the start of the actual code, I don't think it's too early to start trying to use some hygienic naming and organization, and that's what most of my comments are about. On the other hand, if your feeling is that essentially all of this will definitely get thrown away, I could be persuaded by an argument that it's not worth taking the time now. Let me know your thoughts.
@ -10,13 +13,18 @@ pub struct Element {
pub label: String,
pub color: [f32; 3],
Shouldn't we have at least a typedef somewhere for color representations, in case we use a different color space at some point? For example, we are almost certain to want to add an opacity level to our colors. (Certainly the final product will allow you to set the opacity of any element.)
Done, with the type alias
ElementColor
.OK, we will use that for now; the name implies we might have a different type for the color of something else, like the axes in a view (which aren't part of the Assembly). If we do start using this type for the colors of other things, we should rename it, I think.
@ -10,13 +13,18 @@ pub struct Element {
pub label: String,
pub color: [f32; 3],
pub rep: DVector<f64>,
Same comments go for rep here as in Constraint. Maybe it's "representative"? If your worry is that a full word will be too long, look for a shorter word, or pick a unique short acronym for this sort of thing in both places, and then clearly document what that acronym means and why you are using it. Maybe for now "document" just means "comment" until we have a decent doc system in place.
It's for "representation"—a vector representation of the element, acted on by a linear representation of the 3d Möbius group. When I update the PR, I'll expand the property name to
representation
(in commitda008fd
).One other comment: some of the stuff in the assembly is intrinsic, i.e. there is a red sphere, constrained to have radius 1, containing a point with id A labeled
A_{init}
, say. Other stuff is contingent: the current realization of the red sphere has such-and-such representation, and point id A is selected. (I think the selection needs to be constant and shared across all views, so may as well be in the Assembly.) There may turn out to be practical reasons to separate those types of things in some way, but I certainly think there are conceptual advantages to doing so. For now, we may just want to keep in mind to choose names that suggest the intrinsic/contingent divide. It may be that "representation" is sufficiently suggestive in this way, or perhaps a name like "realization" would be better, and "currentRealization" is even stronger in this regard. Similarly, "selection" might already feel contingent enough; "currentlySelected" certainly would. This is not a request for any particular change to the current PR, just an item to keep in mind, although if you are moved to make any changes that's OK too.@ -14,0 +16,4 @@
pub constraints: BTreeSet<usize>,
// internal properties, not reflected in any view
pub index: usize
I am confused; if this is internal, why is it
pub
? If it is important that this property never be reflected in a view (i.e., it is not really an intrinsic property of the element, just a contingent value for programming convenience) then there should be a way to enforce that views don't see it. Does Rust have anything like C++friend
s orprotected
or something? Ideally, anything that shouldn't be accessible in a view would not be exposed in an interface to the assembly that views can get a hold of.Also, I am guessing that this "index" is the same value that will appear for example in the pair in a Constraint that identifies the elements that are being constrained? If so, then the same typedef should be used here, and the names should be harmonized: if you land on
ElementHandle
, say, then this should behandle
.And then to soften my first paragraph: maybe we are encouraging views to use these handles for efficiency? Then we should not describe them as "internal" but instead document that they are not intrinsic properties of an element and not to be relied on across a reload of an assembly, but can be used in XXX circumstances and will be invariant over YYY operations, that sort of thing.
Oh, and then I see that Assembly has an
elements_by_id
property. Once you've settled on names for the relevant concept, say it's anElementHandle
, that should be renamed to something likehandleOfId
orelementHandleOfId
.Note I have been typing camelCase and your code is using snake_case. The former is more compact, and I am quite used to it. But I am not stuck on it. We should discuss and pick a casing convention that suits us both. I give zero weight to Rust's casing conventions; as I said, the rigidity of that community does not seem helpful, and we will be writing in husht anyway.
When an assembly with n elements is sent to the engine, each element gets an index from 1 to n, which tells you which column of the configuration matrix it goes in. The index is assigned and used during the realization routine,
Assembly::realize
; it can change each timerealize
is called. I've put it in theElement
structure, as theindex
field, because that feels like the most foolproof way to ensure consistent indexing of the configuration matrix and the Gram matrix.Good point—it shouldn't be. I made it public in this pull request as a kludge, because I hadn't written a constructor for
Element
yet. The outline cleanup pull request (#16) introduces anElement
constructor, allowing us to makeindex
private with no changes to any other code. I propose making this an issue and then addressing it in #16.Fine, given that #16 is around the corner.
@ -15,4 +21,4 @@
#[derive(Clone)]
pub struct Constraint {
pub args: (usize, usize),
constrains
would work for me, as wouldappliesTo
. Totally open to other choices.usize
s are some sort of references to elements. If a primitive type is being used with semantic baggage, typedef it or something like that so that you can write e.g.,pub constrains: (ElementHandle, ElementHandle)
Again, I am open to other options for the name to use; "ElementRef" would be fine, or you may have another idea. A big part of why to do this is to make development easier should it become convenient to use a different primitive type to encode a reference to an element.
I've switched to
subjects
(in commited1890b
), as in the phrase "[...] subject to the constraint that [...]." Is that a fitting name? I like to name non-boolean variables with nouns, rather than prepositional verbs like "applies to."Right now, there's only one kind of element (a sphere) and one kind of constraint (fixing the generalized angle between two spheres), so the
Element
andConstraint
structures are written pretty narrowly. As we extend dyna3 to more general problems, I'm planning to generalizeElement
andConstraint
as needed—maybe by turning each of them into a trait implemented by a bunch of different structures.Done as soon as I update the PR (in commit
ced001b
). I've chosenElementKey
andConstraintKey
, since the Rust collections I've seen tend to use the language of keys and values.@ -17,3 +23,3 @@
pub struct Constraint {
pub args: (usize, usize),
pub rep: f64,
pub rep: Signal<f64>,
Let's get into a habit of using full words in interfaces/public property names. Looking at this, I don't know if "rep" is a representation, the number of repetitions, something that is being reported, or what... Please rename this suite of properties. Thanks!
I've replaced
rep
withlorentz_prod
in all theConstraint
field names (commit5839882
). We can streamline the names later if they get too unwieldy.@ -79,2 +89,3 @@
rep: DVector::<f64>::from_column_slice(&[0.0, 0.0, 0.0, 0.5, -0.5]),
constraints: BTreeSet::default()
constraints: BTreeSet::default(),
index: 0
I am very confused by
index: 0
. I guess I was wrong that the index is a quick way to get at the element within the assembly? Maybe this will all be clear once the naming of types and properties is a bit improved, but if not, please comment this line.The PR update will add a comment that explains the
index
field's meaning, as described in this comment, and reminds us to makeindex
private when that becomes possible (for example, in pull request #16).Great. And please add to #16 to rename this property to something that has "column" in it -- that's much more informative than index, which could be an index into anything.
@ -92,0 +146,4 @@
/* DEBUG */
// log the initial configuration matrix
console::log_1(&JsValue::from("Old configuration:"));
Do you want to set up now a logging/debugging system so that we can leave messages like this in/out at compile time (so that they have no runtime impact when left out), and so always leave them in the code in the repo? Or do you think the project is premature for that, we will plan to take these out in the fairly near future? Either way is fine, just let me know; I am just making the point that we definitely don't want any raw console logs in our standard code, ultimately.
I think it's too early to design a formal logging system. I've been adding and removing log messages pretty freely as I test new features and track down bugs. I just started a wiki page where we can jot down log messages that come in handy; we can refer to that list when we start thinking about how to log stuff more formally.
@ -92,0 +171,4 @@
}
));
console::log_2(&JsValue::from("Steps:"), &JsValue::from(history.scaled_loss.len() - 1));
console::log_2(&JsValue::from("Loss:"), &JsValue::from(*history.scaled_loss.last().unwrap()));
Are the _1 and _2 suffixes the number of arguments? I take it rust doesn't have functions with a varying number of arguments? Or do they mean something else?
Yes—
log_0
throughlog_7
are convenience wrappers forlog
, which takes an array of things to log.Is that a Rustism? Or a local convenience? It's pretty awful; I hope we can avoid it in the long run, one way or another.
@ -27,0 +120,4 @@
0.0, 0.0, 0.0, -2.0, 0.0
]);
}
The coordinates are reordered from the notes, I believe, with the "position" coordinates first, and then the bend & coradius. And the sign is flipped from that Q, right? But the relative scaling of the position and radius terms seems strange to me; in the notes it's (cr/2 + rc/2) - xx - yy - zz; here it seems to be xx + yy + zz - 2cr -2rc. I know we discussed at one point what effect that relative factor of 4 between the radii and position terms has on all of the claims in the inversive notes; my apologies for having forgotten/still being confused on the details. Please put some comments either here or in the notes file on this topic to clarify how the actual coordinates we are using compare to the ones in the inversive notes. Thanks!
I've added a section about this to the notes!
Ah, that's very helpful. I had forgotten that the coordinates changed, so that the Lorentz product only flipped sign, so that all of the claims about the values of the Lorentz product in the notes are still true for
Q'
just with the signs flipped.Just remember that at whatever point we add a "radius" constraint to a sphere, that the constraint itself is represented by the radius, which is the salient platonic characteristic of that constraint; the fact that the constraint is achieved by setting some coordinate matrix entry to 1/2r is secondary.
In that vein, we should both be clear about the fact that currently a Constraint is represented by "set the Gram matrix entry at this position to blah" is temporary, for this PR and the run-up to definitely settling on a first-effort engine. That's not a proper part of the platonic reality being modeled; it wouldn't be preserved across the switch to a totally different representation/optimization method, which is a good test for what could ultimately be in the Assembly. So as soon as we have decided that this is the engine we're going to go with for "ver 0.1", so to speak, we need an issue to replace the sort of Constraint that's there now with platonically viable ones: "angle between elements", "elements are tangent", "elements are parallel", "point lies on element", etc. etc.; these then get translated in the engine to the forms in which they can be practically enforced in that particular engine.
@ -27,0 +277,4 @@
// --- tests ---
#[cfg(test)]
mod tests {
love that there are tests right in here, we definitely want to keep that up and have them be as pervasive as possible.
Yes, this is one of the recommended ways to organize tests in Rust!
@ -1,12 +1,40 @@
use itertools::Itertools;
Definitely at some point we are going to need to separate the src directory into parts for the Assembly/Engine and for each of the views, but fine if you don't think that's warranted yet. On the other hand, if you just want to set up a reasonable hierarchy now and see how it works, that's fine too.
Yeah, I think we'll probably want separate modules for "front of house" and "back of house" code at some point. Right now it does seem like the
engine
andassembly
modules should go together in the "back of house" module, but I could imagine that changing, since the assembly structure is pretty tailored to the needs of the user interface.Note that the
Display
andOutline
views each have their own module already: that's why the expressionsmod display;
andmod outline;
appear at the top ofmain.rs
.I was commenting on the fact that outline is in the same directory as assembly and engine. I guess the module structure may not be immediately clear to the untrained eye; but directory structure is.
Also be cautious about the notion that "assembly is tailored to needs of UI". The driver for what goes in Assembly is the facts of the platonic universe we are observing. So color and label are OK because the sphere labeled George is red in the platonic universe. But unless "hidden" means that an entity must be hidden in all possible views -- and I doubt it should -- that should not be part of Assembly. (That's just an example, I know there is not a "hidden" property yet.)
Done—by compiling
engine::point
only in test mode, where it's already in use. I'll update the PR once I do the other requested changes.These posts describe some drawbacks of and alternatives to making the build fail whenever there's a warning:
#![deny(warnings)]
deny(warnings)
is actively harmfulTo me, it seems more convenient to just adopt the policy that code must compile without warnings to be merged into main.
OK then we need to prioritize getting the gitea CI running again and add a compile-with-warnings-disallowed CI test. The only real way to maintain a state is to fail at least CI if not every commit if you're not in that state, in my experience.
Yes. In both cases, we're trying to solve a problem like
where
x
is the unknown. The original method was to solve the "collapsed" equationThe new method is to solve the modified equation
which is equivalent from an exact point of view.
In Julia, the new method correctly sets the frozen entries of the Newton step to exactly zero. You can verify this by launching the Julia REPL from
engine-proto/gram-test
and then callingIf you'd like, I could write a test to do the same verification in Rust.
I wouldn't guess this without profiling. It probably depends on the size of the matrix, and I have the impression that memory allocation tends to be much more costly than floating-point operations.
Yes,
engine.rs
could definitely evolve into the production engine!Sure, may as well do the verification. I guess then the difference must be in the other coordinates, in the "x" part in your matrix equations above. They must come out microscopically different in the two versions (how else could they diverge from each other?). So I guess that's not really consequential/alarming.
Oh, right: in the Julia version, you have to print the loss history manually. I've updated the PR to reflect this.
engine::point
when it's used 933f05661drepresentation
inElement
structure da008fd090Element::index
field holds b8ca1139d5align
environment in notes abb9d35335Pull request updated and ready for more review! I think I've made all of the changes you requested; let me know if I missed anything.
@ -19,1 +34,3 @@
pub rep: f64,
pub subjects: (ElementKey, ElementKey),
pub lorentz_prod: Signal<f64>,
pub lorentz_prod_text: Signal<String>,
But now I am confused; is this the string like 0.5 that represents the specified lorentz_prod of the constraint in the outline view? Why is that in the assembly? Or am I misunderstanding the import of this property?
If not, it seems like a violation of separation of concerns. The platonic reality of a constraint is just the number that the lorentz product has to be. The string is just an artifact of the outline view, so it should live there. To drive that point home more, we will surely at some point want to represent Constraints in the Display view. There, we might represent them as little translucent angle symbols, perhaps labeled with the angle in radians or radians/tau. It's unlikely that the raw lorentz product would appear in the graphical view. So an altogether different string would be involved.
Yes. More generally, it's the reactive string that represents the Lorentz product in every view where it's edited or displayed as a string. Right now, this just means the two inputs that appear in the outline view, but in the future it might also include inputs in a Gram matrix view.
I agree that the model and view aspects of the Lorentz product should be separated better. One way might be to write a wrapper around
Constraint
to hold reactive values which need to stay synchronized across several views, but aren't part of the Platonic ideal assembly. However, I'd expect this way—or any other way—to lead to an extra layer of organization over the whole assembly. For that reason, I think that any attempt to do this should be its own dedicated pull request. Right now, I don't see the less-than-ideal organization as a major obstacle to developing a minimum viable product.Especially right now where the only occurrences of the string are in the outline view, it seems absolutely clear that this string (and its validity) should be housed in the view, not in the assembly. It should then be "wired up" so that when a valid string has been entered, that updates the actual constraint in the assembly; and vice-versa, if the actual constraint in the assembly becomes changed (by some other control, say), then the representing view-string should be updated to the string of the new value, and the representing validity-flag should be updated to true.
Just like in Numberscope when you enter invalid input for a parameter, it should not disable the associated entity, it should simply not (yet at that point) update it (so the last valid value will continue to be used, until there's a new valid input in the box). There should of course be some visible indication that the input is in a bad state; if you think it is important enough, that indication could in addition display that previous value that's still being used.
With this design, suppose there is a Gram view in the future. It has its input boxes with their reactive strings. When one of them is updated, that action will update the actual constraint in the Assembly. And that event in turn should update the reactive string in the Outline view. So everything remains harmonized. This is no different from dragging a point onto an entity in the Display to mean that it should have a new constraint of lying on that entity vs selecting the point and entity in the Outline view and hitting the "coincident" tool, or whatever. It's the type of thing that will come up over and over again as we create multiple ways of doing things that amount to the same action in the Assembly -- there will not be any synchronized state kept across views. They should synchronize by virtue of two-way communication with the Assembly.
I think it's important to do it this way, because if you think about actual use cases, I think it will be quite rare that there would really ever be a "reactive string that needs to stay synchronized across views". For example, as dyna3 matures, the outline view is going to express this sort of a constraint as an angle, and it will likely have a degrees or radians or radians/tau selector which will affect what string is used, while the Gram matrix will likely express it as the cosine-value of the angle, which will entail a different string.
For all these reasons, please do change the location/architecture of the reactive values that are not properly part of the model (Assembly) for this PR. Let's keep the models and the view-controllers well separated from the start. Thanks so much for your understanding!
Note that if there is a concept in the Assembly of a "turned-off constraint", then the on/off state of a constraint should be represented as such in the model, and views that display or allow control of that bit should have their needed apparatus to do so.
That seems workable. I could probably implement it by moving the Lorentz product string into the
LorentzProductInput
component introduced in pull request #16.The likely costs of this will be:
Let me know whether you'd still like me to go ahead.
Please file an issue to do this immediately after #16.
@ -20,0 +34,4 @@
pub subjects: (ElementKey, ElementKey),
pub lorentz_prod: Signal<f64>,
pub lorentz_prod_text: Signal<String>,
pub lorentz_prod_valid: Signal<bool>,
This signal keeps track of whether
lorentz_prod_text
can be parsed to a floating point number. Maybe the namelorentz_prod_specified
would be more descriptive?The realization routine only enforces a constraint when
lorentz_prod_valid
is true. This helps ensure that the engine is always enforcing the constraints that the user is looking at. In pull request #16, the interface flags constraints that aren't being enforced because the Lorentz product can't be parsed.Like
lorentz_prod_text
, this signal is closer to the views than to the model, so the discussion about separation of concerns from this comment applies to it too.Yes, I think this will naturally go somewhere else and be called something clearer when the reactive strings that are part of the view go into the view.
Sorry, to be clearer: for this PR, please add a test that verifies, in some reasonably nontrivial case, that when the row/col of a frozen coordinate is set to 00100, the step comes out precisely 0 in that coordinate; I don't see one. Thanks!
Thanks for all of your efforts on this! I think we are close on this one; just the three items from the three comments immediately above, then final review.
Oh and I guess there's one more item: putting engine/assembly in a different directory from views. That one is not critical at this point, since there aren't all that many source files yet, but you should totally feel free to do it at any time to foster a good structure going forward.
Done with
frozen_entry_test
, added in commit5332fda
.To simplify future merges, I'd prefer to do this after the pull requests I already have queued up (#16 and then the upcoming
cargo-examples
pull request).