phetsims / axon

Axon provides powerful and concise models for interactive simulations, based on observable Properties and related patterns.
MIT License
11 stars 8 forks source link

Propagate values to DerivedProperties before notifications are sent out #276

Closed samreid closed 4 years ago

samreid commented 4 years ago

From https://github.com/phetsims/molarity/issues/189#issuecomment-570748713

I think it could help a lot if we flag DerivedProperty listeners for identification, then update all DerivedProperties in a tree before sending any notifications. I don't think that strategy would work for multilink, which is more about side effects.

samreid commented 4 years ago

For initial investigation, I tried adding a separate channel for DerivedProperties, so they can be updated before any notifications are sent out. I tested in the unit test harness.

Before Changes:

setting a to 5
a updated 5+2=3
c updated 5+2=7

After Changes:

setting a to 5
a updated 5+2=7
c updated 5+2=7
```diff Index: js/Property.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/Property.js (revision ce9e7dde68ef1ef397b69f07fb0395d8b9e3e5fb) +++ js/Property.js (date 1578105625812) @@ -141,6 +141,8 @@ // validate the initial value this.validate( value ); } + + this.derivedProperties = []; } /** @@ -188,6 +190,7 @@ */ setPropertyValue( value ) { this._value = value; + this.derivedProperties.forEach( derivedProperty => derivedProperty.recompute() ); } /** Index: js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/DerivedProperty.js (revision ce9e7dde68ef1ef397b69f07fb0395d8b9e3e5fb) +++ js/DerivedProperty.js (date 1578105745091) @@ -60,15 +60,24 @@ this.dependencyListeners = []; for ( let i = 0; i < dependencies.length; i++ ) { - const dependency = dependencies[ i ]; + dependencies[ i ].derivedProperties.push( this ); ( dependency => { const listener = () => { - super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); + super._notifyListeners( null ); + // super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); }; self.dependencyListeners.push( listener ); dependency.lazyLink( listener ); - } )( dependency, i ); + } )( dependencies[ i ], i ); } + + // @private + this.derivation = derivation; + } + + recompute() { + const value = this.derivation.apply( null, this.dependencies.map( p => p.get() ) ); + this.setPropertyValue( value ); } // @public @@ -83,6 +92,7 @@ } this.dependencies = null; this.dependencyListeners = null; + this.derivation = null; super.dispose( this ); } Index: js/DerivedPropertyTests.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/DerivedPropertyTests.js (revision ce9e7dde68ef1ef397b69f07fb0395d8b9e3e5fb) +++ js/DerivedPropertyTests.js (date 1578105087995) @@ -99,4 +99,27 @@ 'DerivedProperty dependency must have boolean value' ); } ); + QUnit.test( 'Auto update', function( assert ) { + assert.ok( true, 'first test' ); + console.log( 'hello' ); + const aProperty = new Property( 1 ); + const bProperty = new Property( 2 ); + let cProperty = null; // When shuffling, derived properties aren't necessarily first any more. Simulate this by adding a listener first + + aProperty.lazyLink( a => { + console.log( 'a updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + bProperty.lazyLink( b => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + cProperty = new DerivedProperty( [ aProperty, bProperty ], ( a, b ) => a + b ); + cProperty.lazyLink( c => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + console.log( 'setting a to 5' ); + aProperty.value = 5; + } ); + } ); \ No newline at end of file ```

My implementation is missing improved Property.dispose to get rid of the derived properties, and I haven't tested that it is fully general to an arbitrary tree size, or if there is a simpler implementation (maybe DerivedProperty doesn't need to link at all).

@zepumph do you think this idea would address the problems we discussed in https://github.com/phetsims/molarity/issues/189#issuecomment-570748713 ?

I also need to fully read through https://github.com/phetsims/axon/issues/209 to see if this pattern addresses any of those problems.

UPDATE: Also, the proposed solution doesn't properly handle oldValue in DerivedProperty notifications.

samreid commented 4 years ago

The problem described in https://github.com/phetsims/phet-io-wrappers/issues/229#issue-397432747 is not between a Property and a chain of DerivedProperties, so this solution is insufficient to solve that problem. setDeferred still works for that problem.

samreid commented 4 years ago

Also, if we know we have to keep setDeferred, should this feature (of getting DerivedProperty values right) be implemented using that feature?

zepumph commented 4 years ago

I have a calendar event set for Feb 5.

samreid commented 4 years ago

Excellent, thanks!

samreid commented 4 years ago

Today @pixelzoom and @chrisklus and I discussed this problem in the context of https://github.com/phetsims/gas-properties/issues/182

We tried storing all of the DerivedProperty instances globally, then after all Property instances had taken their final value (but before notifying listeners), we defer DerivedProperties, and update their values.

This addressed at least one problem in Gas Properties.

We thought this might be more robust than the patch above because the patch above is too granular and may send out change notifications at the wrong times?

```diff Index: main/phet-io/js/PhetioStateEngine.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/phet-io/js/PhetioStateEngine.js (revision 05d8f4de16cf1625a942b71b2243118fff8eeb64) +++ main/phet-io/js/PhetioStateEngine.js (date 1583948670388) @@ -185,7 +185,19 @@ // This emitter will toggle all that support it to be no longer deferred. Do this to every object // before calling listeners in the `actions` stateSetOnceEmitter.emit(); + + // all instrumented Properties have their correct values. We must make sure DerivedProperties also take their correct + // values before sending any notifications, because sometimes Property callbacks check the values of DerivedProperties + + window.allDerivedProperties.forEach( derivedProperty => { + derivedProperty.setDeferred( true ); + derivedProperty.updateAfterSetState(); + } ); + actions.forEach( action => action && action() ); + window.allDerivedProperties.forEach( derivedProperty => { + derivedProperty.setDeferred( false ); + } ); // This is before updating the views to allow for more custom state-setting logic to be notified here. this.stateSetEmitter.emit( state ); Index: main/axon/js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/DerivedProperty.js (revision 79b15bd68b295bd234a1c2260149d53053147ca1) +++ main/axon/js/DerivedProperty.js (date 1583949041195) @@ -14,6 +14,8 @@ import DerivedPropertyIO from './DerivedPropertyIO.js'; import Property from './Property.js'; +window.allDerivedProperties = []; + class DerivedProperty extends Property { /** @@ -25,7 +27,8 @@ options = merge( { tandem: Tandem.OPTIONAL, - phetioReadOnly: true // derived properties can be read but not set by PhET-iO + phetioReadOnly: true, // derived properties can be read but not set by PhET-iO + sketchy: false }, options ); assert && options.tandem.supplied && assert( options.phetioType && options.phetioType.outerType === DerivedPropertyIO, @@ -38,6 +41,9 @@ // We must pass supertype tandem to parent class so addInstance is called only once in the subclassiest constructor. super( initialValue, options ); + // if (brand===phetio) + window.allDerivedProperties.push( this ); + this.dependencies = dependencies; // @private // We can't reset the DerivedProperty, so we don't store the initial value to help prevent memory issues. @@ -65,6 +71,10 @@ dependency.lazyLink( listener ); } )( dependency, i ); } + + this.updateAfterSetState = () => { + super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); + } } // @public Index: main/gas-properties/js/common/model/IdealGasLawContainer.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/gas-properties/js/common/model/IdealGasLawContainer.js (revision da9220367cc9469707f1494b58f143e64e644bc8) +++ main/gas-properties/js/common/model/IdealGasLawContainer.js (date 1583949041201) @@ -226,9 +226,13 @@ else { openingLeft = this.left + this.openingLeftInset; } - assert && assert( openingLeft <= this.getOpeningRight(), - `openingLeft ${openingLeft} must be <= openingRight ${this.getOpeningRight()}` - ); + // assert && assert( openingLeft <= this.getOpeningRight(), + // `openingLeft ${openingLeft} must be <= openingRight ${this.getOpeningRight()}` + // ); + + if ( openingLeft > this.getOpeningRight() ) { + openingLeft = this.getOpeningRight(); + } return openingLeft; } ```

This would be good to discuss with @zepumph

zepumph commented 4 years ago

I am happy to discuss. I poked around with the patch, but I wasn't quite able to understand how this might apply in a general, commitable way (without the global). It also isn't totally clear to me how this applies to phet-io state set vs whenever any Property value is set, and what the desired behavior is for those cases. Let's chat!

zepumph commented 4 years ago

@samreid and I discussed this more today, and he brought me up to speed on the above two patched. We decided that it made the most sense to try to tackle this in a general way in axon instead of something specific to the phet-io state engine. As a result we started from the original patch and then added to it.

@samreid I worked further on this, mainly with tests. There are a few TODOs with open questions, especially as it pertains to the "deferred" derived Property test. Take a look and let me know what you think.

Fuzzing showed a few assertions in diffusion and gas-properties that looked like ". . . has not been populated yet." That seemed worrisome, and I didn't quite understand them. Otherwise tests are running (not like that is the end of the story or anything).

This is all I can do for today:

```diff Index: js/Property.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/Property.js (revision eaf61097674debdb05b1eb29cfc9d83c3c0e5515) +++ js/Property.js (date 1584581418589) @@ -140,6 +140,8 @@ // validate the initial value this.validate( value ); } + + this.derivedProperties = []; } /** @@ -187,6 +189,7 @@ */ setPropertyValue( value ) { this._value = value; + this.derivedProperties.forEach( derivedProperty => derivedProperty.recompute() ); } /** @@ -447,6 +450,7 @@ // remove any listeners that are still attached to this property this.unlinkAll(); + this.derivedProperties.length = 0; // clear all DerivedProperty pointers; super.dispose(); this.changedEmitter.dispose(); Index: js/DerivedPropertyTests.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/DerivedPropertyTests.js (revision eaf61097674debdb05b1eb29cfc9d83c3c0e5515) +++ js/DerivedPropertyTests.js (date 1584581214151) @@ -28,22 +28,18 @@ const listener = function( area ) { /*console.log( 'area = ' + area );*/ }; areaProperty.link( listener ); - assert.equal( widthProperty.changedEmitter.getListenerCount(), 1 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 1 ); - assert.equal( areaProperty.dependencies.length, 2 ); - assert.equal( areaProperty.dependencyListeners.length, 2 ); + assert.ok( widthProperty.derivedProperties.length === 1 ); + assert.ok( heightProperty.derivedProperties.length === 1 ); + assert.ok( areaProperty.dependencies.length === 2 ); // Unlink the listener areaProperty.unlink( listener ); areaProperty.dispose(); - assert.equal( widthProperty.changedEmitter.getListenerCount(), 0 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 0 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 0 ); + assert.ok( widthProperty.derivedProperties.length === 0 ); + assert.ok( heightProperty.derivedProperties.length === 0 ); - assert.equal( areaProperty.dependencies, null ); - assert.equal( areaProperty.dependencyListeners, null ); - assert.equal( areaProperty.dependencyValues, null ); + assert.ok( areaProperty.dependencies === null ); } ); @@ -63,14 +59,25 @@ const derivedProperty = new DerivedProperty( [ property1, property2 ], ( a, b ) => a + b ); assert.ok( derivedProperty.value === 2, 'base case, no defer' ); + let calledCount = 0; + derivedProperty.link( () => calledCount++ ); + assert.ok( calledCount === 1, 'already called once' ); + // test a dependency being deferred property1.setDeferred( true ); assert.ok( derivedProperty.value === 2, 'same value even after defer' ); + assert.ok( calledCount === 1, 'and listener has not been called yet' ); property1.value = 2; + + // TODO: is this buggy? Shouldn't this automatically update with the new system? assert.ok( derivedProperty.value === 2, 'same value even when set to new' ); + assert.ok( calledCount === 1, 'and listener has not been called yet 2' ); const update = property1.setDeferred( false ); assert.ok( property1.value === 2, 'property has new value now' ); - assert.ok( derivedProperty.value === 2, 'but the derivedProperty doesnt' ); + assert.ok( derivedProperty.value === 4, 'the derivedProperty was already updated as a value' ); + + // because the derivedProperty is not deferred + assert.ok( calledCount === 2, 'and listener is called immediately' ); update(); assert.ok( derivedProperty.value === 4, 'now derivedProperty was updated' ); @@ -81,8 +88,11 @@ assert.ok( derivedProperty.value === 4, 'still 4 after update' ); const updateAgain = derivedProperty.setDeferred( false ); assert.ok( derivedProperty.value === 6, 'now has the correct value' ); + assert.ok( calledCount === 2, 'should not have been called yet' ); + assert.ok( typeof updateAgain === 'function', 'should be a callback because the derivedProperty changed' ); updateAgain(); assert.ok( derivedProperty.value === 6, 'nothing changed' ); + assert.ok( calledCount === 3, 'now the listener is called (2)' ); } ); QUnit.test( 'DerivedProperty and/or', function( assert ) { @@ -122,4 +132,28 @@ // fail: setting a dependency to a non-boolean value window.assert && assert.throws( function() { propA.value = 0; }, 'DerivedProperty dependency must have boolean value' ); -} ); \ No newline at end of file +} ); + + +QUnit.test( 'Auto update', function( assert ) { + assert.ok( true, 'first test' ); + console.log( 'hello' ); + const aProperty = new Property( 1 ); + const bProperty = new Property( 2 ); + let cProperty = null; // When shuffling, derived properties aren't necessarily first any more. Simulate this by adding a listener first + + aProperty.lazyLink( a => { + console.log( 'a updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + bProperty.lazyLink( b => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + cProperty = new DerivedProperty( [ aProperty, bProperty ], ( a, b ) => a + b ); + cProperty.lazyLink( c => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + console.log( 'setting a to 5' ); + aProperty.value = 5; +} ); Index: js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/DerivedProperty.js (revision eaf61097674debdb05b1eb29cfc9d83c3c0e5515) +++ js/DerivedProperty.js (date 1584581418591) @@ -50,21 +50,24 @@ assert && assert( options.phetioType.outerType === DerivedPropertyIO, 'phetioType should be DerivedPropertyIO' ); } - const self = this; - - // @private Keep track of listeners so they can be detached - this.dependencyListeners = []; - for ( let i = 0; i < dependencies.length; i++ ) { - const dependency = dependencies[ i ]; - ( dependency => { - const listener = () => { - super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); - }; - self.dependencyListeners.push( listener ); - dependency.lazyLink( listener ); - } )( dependency, i ); + assert && assert( dependencies[ i ] instanceof Property ); + dependencies[ i ].derivedProperties.push( this ); } + + // @private + this.derivation = derivation; + } + + /** + * Recalculate the current value from the dependencies and set that value. + * @public (axon-internal) + */ + recompute() { + + // Note to @samreid - It is important to use `set` here to make sure deferred works as expected. + // TODO: assert that none of these are disposed? + super.set( this.derivation.apply( null, this.dependencies.map( p => p.get() ) ) ); } // @public @@ -74,11 +77,13 @@ for ( let i = 0; i < this.dependencies.length; i++ ) { const dependency = this.dependencies[ i ]; if ( !dependency.isDisposed ) { - dependency.unlink( this.dependencyListeners[ i ] ); + const index = dependency.derivedProperties.indexOf( this ); + assert && assert( index !== -1, 'dependency is not in dependencies list' ); + dependency.derivedProperties.splice( index, 1 ); } } this.dependencies = null; - this.dependencyListeners = null; + this.derivation = null; super.dispose( this ); } ```

Here are the errors in phet brand:

``` diffusion Uncaught Error: Assertion failed: particles2 has not been populated yet Error: Assertion failed: particles2 has not been populated yet at window.assertions.assertFunction (http://localhost:8080/assert/js/assert.js?bust=1584581830835:22:13) at DiffusionModel.numberOfParticlesProperty.DerivedProperty.numberType (http://localhost:8080/gas-properties/js/diffusion/model/DiffusionModel.js:109:21) at DerivedProperty.recompute (http://localhost:8080/axon/js/DerivedProperty.js:70:32) at http://localhost:8080/axon/js/Property.js:192:72 at Array.forEach () at NumberProperty.setPropertyValue (http://localhost:8080/axon/js/Property.js:192:28) at NumberProperty.set (http://localhost:8080/axon/js/Property.js:176:14) at NumberSpinner.incrementFunction (http://localhost:8080/sun/js/NumberSpinner.js:152:20) at TinyEmitter.emit (http://localhost:8080/axon/js/TinyEmitter.js:65:53) at http://localhost:8080/axon/js/Emitter.js:33:29 gas-properties Uncaught Error: Assertion failed: lightParticles has not been populated yet Error: Assertion failed: lightParticles has not been populated yet at window.assertions.assertFunction (http://localhost:8080/assert/js/assert.js?bust=1584582058031:22:13) at http://localhost:8080/gas-properties/js/common/model/ParticleSystem.js:109:19 at DerivedProperty.recompute (http://localhost:8080/axon/js/DerivedProperty.js:70:32) at http://localhost:8080/axon/js/Property.js:192:72 at Array.forEach () at NumberProperty.setPropertyValue (http://localhost:8080/axon/js/Property.js:192:28) at NumberProperty.set (http://localhost:8080/axon/js/Property.js:176:14) at NumberProperty.set value [as value] (http://localhost:8080/axon/js/Property.js:349:32) at http://localhost:8080/scenery-phet/js/FineCoarseSpinner.js:120:28 at TinyEmitter.emit (http://localhost:8080/axon/js/TinyEmitter.js:65:53) gas-properties Uncaught Error: Assertion failed: heavyParticles has not been populated yet Error: Assertion failed: heavyParticles has not been populated yet at window.assertions.assertFunction (http://localhost:8080/assert/js/assert.js?bust=1584582058031:22:13) at http://localhost:8080/gas-properties/js/common/model/ParticleSystem.js:107:19 at DerivedProperty.recompute (http://localhost:8080/axon/js/DerivedProperty.js:70:32) at http://localhost:8080/axon/js/Property.js:192:72 at Array.forEach () at NumberProperty.setPropertyValue (http://localhost:8080/axon/js/Property.js:192:28) at NumberProperty.set (http://localhost:8080/axon/js/Property.js:176:14) at NumberProperty.set value [as value] (http://localhost:8080/axon/js/Property.js:349:32) at http://localhost:8080/scenery-phet/js/FineCoarseSpinner.js:114:28 at TinyEmitter.emit (http://localhost:8080/axon/js/TinyEmitter.js:65:53) gases-intro Uncaught Error: Assertion failed: heavyParticles has not been populated yet Error: Assertion failed: heavyParticles has not been populated yet at window.assertions.assertFunction (http://localhost:8080/assert/js/assert.js?bust=1584582064899:22:13) at http://localhost:8080/gas-properties/js/common/model/ParticleSystem.js:107:19 at DerivedProperty.recompute (http://localhost:8080/axon/js/DerivedProperty.js:70:32) at http://localhost:8080/axon/js/Property.js:192:72 at Array.forEach () at NumberProperty.setPropertyValue (http://localhost:8080/axon/js/Property.js:192:28) at NumberProperty.set (http://localhost:8080/axon/js/Property.js:176:14) at NumberProperty.set value [as value] (http://localhost:8080/axon/js/Property.js:349:32) at http://localhost:8080/scenery-phet/js/FineCoarseSpinner.js:114:28 at TinyEmitter.emit (http://localhost:8080/axon/js/TinyEmitter.js:65:53) ```
samreid commented 4 years ago

@zepumph discussed this a bit more, particularly about how to make sure the Property listeners are notified before the DerivedProperty listeners. Here's a patch which may or may not be helpful! We are struggling to get all of these features working in concert:

```diff Index: js/Property.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/Property.js (revision eaf61097674debdb05b1eb29cfc9d83c3c0e5515) +++ js/Property.js (date 1584746094124) @@ -140,6 +140,8 @@ // validate the initial value this.validate( value ); } + + this.derivedProperties = []; } /** @@ -172,7 +174,9 @@ else if ( !this.equalsValue( value ) ) { const oldValue = this.get(); this.setPropertyValue( value ); + this.derivedProperties.forEach( derivedProperty => derivedProperty.recompute() ); this._notifyListeners( oldValue ); + //this.derivedProperties.forEach( derivedProperty => derivedProperty.setDeferred(false) ); } } return this; @@ -187,6 +191,8 @@ */ setPropertyValue( value ) { this._value = value; + // this.derivedProperties.forEach( derivedProperty => derivedProperty.setDeferred(true) ); + } /** @@ -447,6 +453,7 @@ // remove any listeners that are still attached to this property this.unlinkAll(); + this.derivedProperties.length = 0; // clear all DerivedProperty pointers; super.dispose(); this.changedEmitter.dispose(); Index: js/DerivedPropertyTests.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/DerivedPropertyTests.js (revision eaf61097674debdb05b1eb29cfc9d83c3c0e5515) +++ js/DerivedPropertyTests.js (date 1584744172688) @@ -28,22 +28,18 @@ const listener = function( area ) { /*console.log( 'area = ' + area );*/ }; areaProperty.link( listener ); - assert.equal( widthProperty.changedEmitter.getListenerCount(), 1 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 1 ); - assert.equal( areaProperty.dependencies.length, 2 ); - assert.equal( areaProperty.dependencyListeners.length, 2 ); + assert.ok( widthProperty.derivedProperties.length === 1 ); + assert.ok( heightProperty.derivedProperties.length === 1 ); + assert.ok( areaProperty.dependencies.length === 2 ); // Unlink the listener areaProperty.unlink( listener ); areaProperty.dispose(); - assert.equal( widthProperty.changedEmitter.getListenerCount(), 0 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 0 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 0 ); + assert.ok( widthProperty.derivedProperties.length === 0 ); + assert.ok( heightProperty.derivedProperties.length === 0 ); - assert.equal( areaProperty.dependencies, null ); - assert.equal( areaProperty.dependencyListeners, null ); - assert.equal( areaProperty.dependencyValues, null ); + assert.ok( areaProperty.dependencies === null ); } ); @@ -63,14 +59,25 @@ const derivedProperty = new DerivedProperty( [ property1, property2 ], ( a, b ) => a + b ); assert.ok( derivedProperty.value === 2, 'base case, no defer' ); + let calledCount = 0; + derivedProperty.link( () => calledCount++ ); + assert.ok( calledCount === 1, 'already called once' ); + // test a dependency being deferred property1.setDeferred( true ); assert.ok( derivedProperty.value === 2, 'same value even after defer' ); + assert.ok( calledCount === 1, 'and listener has not been called yet' ); property1.value = 2; + + // TODO: is this buggy? Shouldn't this automatically update with the new system? assert.ok( derivedProperty.value === 2, 'same value even when set to new' ); + assert.ok( calledCount === 1, 'and listener has not been called yet 2' ); const update = property1.setDeferred( false ); assert.ok( property1.value === 2, 'property has new value now' ); - assert.ok( derivedProperty.value === 2, 'but the derivedProperty doesnt' ); + assert.ok( derivedProperty.value === 4, 'the derivedProperty was already updated as a value' ); + + // because the derivedProperty is not deferred + assert.ok( calledCount === 2, 'and listener is called immediately' ); update(); assert.ok( derivedProperty.value === 4, 'now derivedProperty was updated' ); @@ -81,8 +88,11 @@ assert.ok( derivedProperty.value === 4, 'still 4 after update' ); const updateAgain = derivedProperty.setDeferred( false ); assert.ok( derivedProperty.value === 6, 'now has the correct value' ); + assert.ok( calledCount === 2, 'should not have been called yet' ); + assert.ok( typeof updateAgain === 'function', 'should be a callback because the derivedProperty changed' ); updateAgain(); assert.ok( derivedProperty.value === 6, 'nothing changed' ); + assert.ok( calledCount === 3, 'now the listener is called (2)' ); } ); QUnit.test( 'DerivedProperty and/or', function( assert ) { @@ -122,4 +132,28 @@ // fail: setting a dependency to a non-boolean value window.assert && assert.throws( function() { propA.value = 0; }, 'DerivedProperty dependency must have boolean value' ); -} ); \ No newline at end of file +} ); + + +QUnit.test( 'Auto update', function( assert ) { + assert.ok( true, 'first test' ); + console.log( 'hello' ); + const aProperty = new Property( 1 ); + const bProperty = new Property( 2 ); + let cProperty = null; // When shuffling, derived properties aren't necessarily first any more. Simulate this by adding a listener first + + aProperty.lazyLink( a => { + console.log( 'a updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + bProperty.lazyLink( b => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + cProperty = new DerivedProperty( [ aProperty, bProperty ], ( a, b ) => a + b ); + cProperty.lazyLink( c => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + console.log( 'setting a to 5' ); + aProperty.value = 5; +} ); Index: js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/DerivedProperty.js (revision eaf61097674debdb05b1eb29cfc9d83c3c0e5515) +++ js/DerivedProperty.js (date 1584745757205) @@ -50,21 +50,35 @@ assert && assert( options.phetioType.outerType === DerivedPropertyIO, 'phetioType should be DerivedPropertyIO' ); } - const self = this; - - // @private Keep track of listeners so they can be detached - this.dependencyListeners = []; - for ( let i = 0; i < dependencies.length; i++ ) { const dependency = dependencies[ i ]; ( dependency => { const listener = () => { - super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); + super._notifyListeners( this.priorDerivedPropertyValue ); + // super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); }; - self.dependencyListeners.push( listener ); + // self.dependencyListeners.push( listener ); dependency.lazyLink( listener ); } )( dependency, i ); } + + // @private + this.derivation = derivation; + + this.recompute(); + } + + /** + * Recalculate the current value from the dependencies and set that value. + * @public (axon-internal) + */ + recompute() { + + this.priorDerivedPropertyValue = this.value; + + // Note to @samreid - It is important to use `set` here to make sure deferred works as expected. + // TODO: assert that none of these are disposed? + super.setPropertyValue( this.derivation.apply( null, this.dependencies.map( p => p.get() ) ) ); } // @public @@ -74,11 +88,13 @@ for ( let i = 0; i < this.dependencies.length; i++ ) { const dependency = this.dependencies[ i ]; if ( !dependency.isDisposed ) { - dependency.unlink( this.dependencyListeners[ i ] ); + const index = dependency.derivedProperties.indexOf( this ); + assert && assert( index !== -1, 'dependency is not in dependencies list' ); + dependency.derivedProperties.splice( index, 1 ); } } this.dependencies = null; - this.dependencyListeners = null; + this.derivation = null; super.dispose( this ); } ```
samreid commented 4 years ago

I considered whether Multilink should take precedence like we are discussing for DerivedProperty. It seems many occurrences of Multilink may be better off using DerivedProperty, such as:

    // Only show the AngleNode when it is selected via a checkbox and the laser is on
    Property.multilink( [ showAnglesProperty, laserOnProperty ], ( showAngles, laserOn ) => {
      this.visible = showAngles && laserOn;
    } );

May be written as:

    new DerivedProperty( [ showAnglesProperty, laserOnProperty ], ( showAngles, laserOn ) =>
      showAngles && laserOn
    ).linkAttribute( this, 'visible' )

On second thought, that doesn't seem like a clear win.

It seems like many occurrences of Multilink are treated like Property listeners, and may not need precedence. But I'm not sure how this should be decided. One other possibility would be if they use the same infrastructure, perhaps we could share code between DerivedProperty and Multilink.,

samreid commented 4 years ago

Is it important that the Property keeps its current value when changing to a deferred value? If not, maybe it would be simpler if both Property and DerivedProperty could mark dirty=true if they take a value that doesn't match their current value, and they have not sent notifications yet. But I'm wondering if PhET-iO requires the Property to keep its old value. But there's another issue about not reading from Property at all while it is deferred. Maybe we would have the same rule for dirty?

Maybe we should discuss https://github.com/phetsims/axon/issues/281 further before proceeding on this issue?

samreid commented 4 years ago

I worked on this more today, with a new philosophy:

This significantly cleaned up the setDeferred implementation (even though it changes the semantics), and gives us control over the listener notifications.

Things are mostly working, but several simulations have a problem in PaintColorProperty disposal:

testSims=build-a-fraction,build-an-atom,diffusion,expression-exchange,fraction-matcher,fractions-equality,fractions-intro,fractions-mixed-numbers,gas-properties,gases-intro,isotopes-and-atomic-mass,scenery-phet,sun,unit-rates

I've also lost the ability to error out on a re-entrant Property, but we may be able to restore that, if it's still desirable.

Patch:

```diff Index: main/axon/js/Property.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/Property.js (revision 80100758674420bb80385119d8f4331eb628a737) +++ main/axon/js/Property.js (date 1585500351818) @@ -89,12 +89,15 @@ 'phetioType parameter type must be specified (only one). Tandem.phetioID: ' + this.tandem.phetioID ); } - // @private - Store the internal value and the initial value + // @private {*} - Store the internal value this._value = value; - // @protected - Initial value + // @protected {*} - Initial value this._initialValue = value; + // @protected {*} - the value that was previously sent out to listeners + this.oldValue = value; // TODO: does this have the right behavior? Should we be more explicit about 1st notification? + // @public (phet-io) this.validValues = options.validValues; @@ -114,12 +117,6 @@ // send notifications. this.isDeferred = false; - // @private {*} - the value that this Property will take after no longer deferred - this.deferredValue = null; - - // @private {boolean} whether a deferred value has been set - this.hasDeferredValue = false; - // @protected {function|null} (read-only) - closure over options for validation in set(), filled in below when assertions enabled this.validate = null; @@ -140,6 +137,8 @@ // validate the initial value this.validate( value ); } + + this.derivedProperties = []; } /** @@ -150,6 +149,8 @@ * @public */ get() { + + // TODO: Should we error if trying to get a value of a disposed or deferred property? return this._value; } @@ -165,17 +166,10 @@ set( value ) { if ( !this.isDisposed ) { this.validate && this.validate( value ); - if ( this.isDeferred ) { - this.deferredValue = value; - this.hasDeferredValue = true; - } - else if ( !this.equalsValue( value ) ) { - const oldValue = this.get(); - this.setPropertyValue( value ); - this._notifyListeners( oldValue ); - } - } - return this; + this.setPropertyValue( value ); + this._notifyListeners(); + return this; + } } /** @@ -187,6 +181,7 @@ */ setPropertyValue( value ) { this._value = value; + this.derivedProperties.forEach( derivedProperty => derivedProperty.recompute() ); } /** @@ -254,29 +249,41 @@ } /** - * @param {*} oldValue * @private - but note that a few sims are calling this even though they shouldn't */ - _notifyListeners( oldValue ) { + _notifyListeners( forceNotify ) { + + // This is a no-op for disposed Property instances, to gracefully allow dispose phases + // If the property is deferred, no notifications are sent out. + // If the value is the same as last notification, no notifications are sent out + if ( !this.isDisposed && !this.isDeferred && ( !this.equalsValue( this.oldValue ) || forceNotify ) && !this.notifying ) { - this.isPhetioInstrumented() && this.phetioStartEvent( Property.CHANGED_EVENT_NAME, { - getData: () => { - const parameterType = this.phetioType.parameterTypes[ 0 ]; - return { - oldValue: NullableIO( parameterType ).toStateObject( oldValue ), - newValue: parameterType.toStateObject( this.get() ) - }; - } - } ); + this.isPhetioInstrumented() && this.phetioStartEvent( Property.CHANGED_EVENT_NAME, { + getData: () => { + const parameterType = this.phetioType.parameterTypes[ 0 ]; + return { + oldValue: NullableIO( parameterType ).toStateObject( this.oldValue ), + newValue: parameterType.toStateObject( this.get() ) + }; + } + } ); - // notify listeners, optionally detect loops where this Property is set again before this completes. - assert && assert( !this.notifying || this.reentrant, - 'reentry detected, value=' + this.get() + ', oldValue=' + oldValue ); - this.notifying = true; - this.changedEmitter.emit( this.get(), oldValue, this ); - this.notifying = false; + // notify listeners, optionally detect loops where this Property is set again before this completes. + // TODO: error out on re-entry? + // assert && assert( !this.notifying || this.reentrant, 'reentry detected, value=' + this._value + ', oldValue=' + this.oldValue ); + + this.notifying = true; + + this.changedEmitter.emit( this._value, this.oldValue, this ); + this.oldValue = this._value; + + // DerivedProperty listeners are notified after Property listeners + this.derivedProperties.forEach( derivedProperty => derivedProperty._notifyListeners() ); + + this.notifying = false; - this.isPhetioInstrumented() && this.phetioEndEvent(); + this.isPhetioInstrumented() && this.phetioEndEvent(); + } } /** @@ -288,7 +295,7 @@ * @public */ notifyListenersStatic() { - this._notifyListeners( null ); + this._notifyListeners( true ); } /** @@ -297,38 +304,16 @@ * once other Properties have also taken their deferred values. * * @param {boolean} isDeferred - whether the Property should be deferred or not - * @returns {function|null} - function to notify listeners after calling setDeferred(false), - * - null if isDeferred is true, or if the value is unchanged since calling setDeferred(true) * @public */ setDeferred( isDeferred ) { assert && assert( !this.isDisposed, 'cannot defer Property if already disposed.' ); assert && assert( typeof isDeferred === 'boolean', 'bad value for isDeferred' ); - if ( isDeferred ) { - assert && assert( !this.isDeferred, 'Property already deferred' ); - this.isDeferred = true; + assert && assert( this.isDeferred !== isDeferred, 'Cannot specify same value for isDeferred: ', isDeferred ); + this.isDeferred = isDeferred; + if ( !isDeferred ) { + this._notifyListeners(); } - else { - assert && assert( this.isDeferred, 'Property wasn\'t deferred' ); - this.isDeferred = false; - - const oldValue = this._value; - - // Take the new value - if ( this.hasDeferredValue ) { - this.setPropertyValue( this.deferredValue ); - this.hasDeferredValue = false; - } - - // If the value has changed, prepare to send out notifications (after all other Properties in this transaction - // have their final values) - if ( !this.equalsValue( oldValue ) ) { - return () => !this.isDisposed && this._notifyListeners( oldValue ); - } - } - - // no action to signify change - return null; } /** @@ -447,6 +432,7 @@ // remove any listeners that are still attached to this property this.unlinkAll(); + this.derivedProperties.length = 0; // clear all DerivedProperty pointers; super.dispose(); this.changedEmitter.dispose(); Index: main/axon/js/NumberPropertyTests.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/NumberPropertyTests.js (revision 80100758674420bb80385119d8f4331eb628a737) +++ main/axon/js/NumberPropertyTests.js (date 1585493247878) @@ -151,11 +151,9 @@ assert.ok( pCalled === 0, 'p is still deferred, should not call listeners' ); p.rangeProperty.set( new Range( 2, 3 ) ); assert.ok( pRangeCalled === 0, 'p.rangeProperty is still deferred, should not call listeners' ); - const notifyPListeners = p.setDeferred( false ); - notifyPListeners(); + p.setDeferred( false ); assert.ok( pCalled === 1, 'p listeners should have been called' ); - const notifyRangeListeners = p.rangeProperty.setDeferred( false ); - notifyRangeListeners(); + p.rangeProperty.setDeferred( false ); assert.ok( pRangeCalled === 1, 'p.rangeProperty is still deferred, should not call listeners' ); p.setValueAndRange( -100, new Range( -101, -99 ) ); Index: main/phet-io/js/PhetioStateEngine.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/phet-io/js/PhetioStateEngine.js (revision 10e6ea52431d5f7f1d900cf5db9eca8100dfbcdb) +++ main/phet-io/js/PhetioStateEngine.js (date 1585493501853) @@ -312,7 +312,7 @@ // only do this if the PhetioObject is already not deferred if ( phetioObject.setDeferred && !phetioObject.isDeferred ) { phetioObject.setDeferred( true ); - stateSetOnceEmitter.addListener( () => actions.push( phetioObject.setDeferred( false ) ) ); + stateSetOnceEmitter.addListener( () => actions.push( () => phetioObject.setDeferred( false ) ) ); } type.setValue && type.setValue( phetioObject, value ); } Index: main/axon/js/DerivedPropertyTests.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/DerivedPropertyTests.js (revision 80100758674420bb80385119d8f4331eb628a737) +++ main/axon/js/DerivedPropertyTests.js (date 1585492926417) @@ -28,22 +28,18 @@ const listener = function( area ) { /*console.log( 'area = ' + area );*/ }; areaProperty.link( listener ); - assert.equal( widthProperty.changedEmitter.getListenerCount(), 1 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 1 ); - assert.equal( areaProperty.dependencies.length, 2 ); - assert.equal( areaProperty.dependencyListeners.length, 2 ); + assert.ok( widthProperty.derivedProperties.length === 1 ); + assert.ok( heightProperty.derivedProperties.length === 1 ); + assert.ok( areaProperty.dependencies.length === 2 ); // Unlink the listener areaProperty.unlink( listener ); areaProperty.dispose(); - assert.equal( widthProperty.changedEmitter.getListenerCount(), 0 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 0 ); - assert.equal( heightProperty.changedEmitter.getListenerCount(), 0 ); + assert.ok( widthProperty.derivedProperties.length === 0 ); + assert.ok( heightProperty.derivedProperties.length === 0 ); - assert.equal( areaProperty.dependencies, null ); - assert.equal( areaProperty.dependencyListeners, null ); - assert.equal( areaProperty.dependencyValues, null ); + assert.ok( areaProperty.dependencies === null ); } ); @@ -58,31 +54,39 @@ } ); QUnit.test( 'Test defer', function( assert ) { - const property1 = new Property( 0 ); + const property1 = new Property( 1 ); const property2 = new Property( 2 ); const derivedProperty = new DerivedProperty( [ property1, property2 ], ( a, b ) => a + b ); - assert.ok( derivedProperty.value === 2, 'base case, no defer' ); + assert.ok( derivedProperty.value === 3, 'base case, no defer' ); + + let numberDerivedPropertyNotifications = 0; + derivedProperty.link( () => numberDerivedPropertyNotifications++ ); + assert.ok( numberDerivedPropertyNotifications === 1, 'already called once' ); // test a dependency being deferred property1.setDeferred( true ); - assert.ok( derivedProperty.value === 2, 'same value even after defer' ); - property1.value = 2; - assert.ok( derivedProperty.value === 2, 'same value even when set to new' ); - const update = property1.setDeferred( false ); - assert.ok( property1.value === 2, 'property has new value now' ); - assert.ok( derivedProperty.value === 2, 'but the derivedProperty doesnt' ); - update(); - assert.ok( derivedProperty.value === 4, 'now derivedProperty was updated' ); + assert.ok( derivedProperty.value === 3, 'same value even after defer' ); + assert.ok( numberDerivedPropertyNotifications === 1, 'and listener has not been called yet' ); + property1.value = 10; + assert.ok( derivedProperty.value === 12, 'value should update immediately, even though notifications havent been sent' ); + assert.ok( numberDerivedPropertyNotifications === 1, 'and listener still should not be called' ); + property1.setDeferred( false ); + assert.ok( property1.value === 10, 'property still has the new value' ); + assert.ok( derivedProperty.value === 12, 'the derivedProperty was already updated as a value' ); + + // because the derivedProperty is not deferred + assert.ok( numberDerivedPropertyNotifications === 2, 'DerivedPropertyListener should be called when deferrment ends' ); + assert.ok( derivedProperty.value === 12, 'now derivedProperty was updated' ); // test the DerivedProperty being deferred derivedProperty.setDeferred( true ); - assert.ok( derivedProperty.value === 4, 'still 4' ); - property1.value = 4; - assert.ok( derivedProperty.value === 4, 'still 4 after update' ); - const updateAgain = derivedProperty.setDeferred( false ); - assert.ok( derivedProperty.value === 6, 'now has the correct value' ); - updateAgain(); - assert.ok( derivedProperty.value === 6, 'nothing changed' ); + assert.ok( derivedProperty.value === 12, 'still 12' ); + property1.value = 100; + assert.ok( derivedProperty.value === 102, 'derived property takes its new value right away, even though notifications arent sent' ); + assert.ok( numberDerivedPropertyNotifications === 2, 'should NOT have been notified' ); + derivedProperty.setDeferred( false ); + assert.ok( derivedProperty.value === 102, 'now has the correct value' ); + assert.ok( numberDerivedPropertyNotifications === 3, 'should have been notified' ); } ); QUnit.test( 'DerivedProperty and/or', function( assert ) { @@ -122,4 +126,28 @@ // fail: setting a dependency to a non-boolean value window.assert && assert.throws( function() { propA.value = 0; }, 'DerivedProperty dependency must have boolean value' ); -} ); \ No newline at end of file +} ); + + +QUnit.test( 'Auto update', function( assert ) { + assert.ok( true, 'first test' ); + console.log( 'hello' ); + const aProperty = new Property( 1 ); + const bProperty = new Property( 2 ); + let cProperty = null; // When shuffling, derived properties aren't necessarily first any more. Simulate this by adding a listener first + + aProperty.lazyLink( a => { + console.log( 'a updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + bProperty.lazyLink( b => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + cProperty = new DerivedProperty( [ aProperty, bProperty ], ( a, b ) => a + b ); + cProperty.lazyLink( c => { + console.log( 'c updated', `${aProperty.value}+${bProperty.value}=${cProperty.value}` ); + } ); + + console.log( 'setting a to 5' ); + aProperty.value = 5; +} ); Index: main/axon/js/PropertyTests.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/PropertyTests.js (revision 80100758674420bb80385119d8f4331eb628a737) +++ main/axon/js/PropertyTests.js (date 1585493059663) @@ -54,22 +54,20 @@ QUnit.test( 'Test defer', function( assert ) { const property = new Property( 0 ); - let callbacks = 0; - property.lazyLink( function( newValue, oldValue ) { - callbacks++; + let callbackCount = 0; + property.lazyLink( ( newValue, oldValue ) => { + callbackCount++; assert.equal( newValue, 2, 'newValue should be the final value after the transaction' ); assert.equal( oldValue, 0, 'oldValue should be the original value before the transaction' ); } ); property.setDeferred( true ); property.value = 1; property.value = 2; - assert.equal( property.value, 0, 'should have original value' ); - const update = property.setDeferred( false ); - assert.equal( callbacks, 0, 'should not call back while deferred' ); + assert.equal( property.value, 2, 'should take new value right away' ); + assert.equal( callbackCount, 0, 'shouldnt have been called back yet' ); + property.setDeferred( false ); + assert.equal( callbackCount, 1, 'should have been called once after defer ends' ); assert.equal( property.value, 2, 'should have new value' ); - update(); - assert.equal( callbacks, 1, 'should have been called back after update' ); - assert.equal( property.value, 2, 'should take final value' ); } ); QUnit.test( 'Property ID checks', function( assert ) { Index: main/axon/js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/DerivedProperty.js (revision 80100758674420bb80385119d8f4331eb628a737) +++ main/axon/js/DerivedProperty.js (date 1585499863569) @@ -40,6 +40,12 @@ this.dependencies = dependencies; // @private + // Register with dependencies + this.dependencies.forEach( dependency => dependency.derivedProperties.push( this ) ); + + // @private + this.derivation = derivation; + // We can't reset the DerivedProperty, so we don't store the initial value to help prevent memory issues. // See https://github.com/phetsims/axon/issues/193 this._initialValue = null; @@ -49,22 +55,16 @@ // The phetioType should be a concrete (instantiated) DerivedPropertyIO, hence we must check its outer type assert && assert( options.phetioType.outerType === DerivedPropertyIO, 'phetioType should be DerivedPropertyIO' ); } - - const self = this; + } - // @private Keep track of listeners so they can be detached - this.dependencyListeners = []; + /** + * Recalculate the current value from the dependencies and take that value. Listeners will be notified in another step. + * @public (axon-internal) + */ + recompute() { - for ( let i = 0; i < dependencies.length; i++ ) { - const dependency = dependencies[ i ]; - ( dependency => { - const listener = () => { - super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); - }; - self.dependencyListeners.push( listener ); - dependency.lazyLink( listener ); - } )( dependency, i ); - } + // TODO: assert that none of these are disposed? + super.setPropertyValue( this.derivation.apply( null, this.dependencies.map( p => p.get() ) ) ); } // @public @@ -74,11 +74,13 @@ for ( let i = 0; i < this.dependencies.length; i++ ) { const dependency = this.dependencies[ i ]; if ( !dependency.isDisposed ) { - dependency.unlink( this.dependencyListeners[ i ] ); + const index = dependency.derivedProperties.indexOf( this ); + assert && assert( index !== -1, 'dependency is not in dependencies list' ); + dependency.derivedProperties.splice( index, 1 ); } } this.dependencies = null; - this.dependencyListeners = null; + this.derivation = null; super.dispose( this ); } ```
samreid commented 4 years ago

Another idea that may help. At the end of _notifyListeners check if the current value has changed. If so, run _notifyListeners again. I'm also curious if a Property should notify for every single value change, or if some (such as re-entrant ones) should be "batched" and omitted. But I wonder if the batching/skipping is causing the PaintColorProperty disposal problem. It seems Property in master has an implicit list of new values, and notifies for every single value. I'm not sure how to reconcile that with the proposal, unless we make the new values explicit (say in a list or queue).

zepumph commented 4 years ago

I'm assigned here, but I don't have a good concept of what to do. It seems like we will need to pair to bring us to a commit. I have questions about some of the above. I'm not clear about how to handle re-entrant Properties, but I would lean towards doing the exact same thing as we do now, and perhaps change that, if desired, in another change set.

I think in general Multlink should not be handled the same, since it is more generally just following the traditional "listener" pattern. A lack of value attached to the Multilink is key there, and I don't think we should change it as brainstormed in https://github.com/phetsims/axon/issues/276#issuecomment-605518935

@samreid want to pair on this sometime soon and try to finish it up?

zepumph commented 4 years ago

Today @samreid and I worked on this for many hours. Here is a bit how the day went:

  1. It would be nice to solve this generally in axon. What if DerivedProperty values are changed before all notifications go out. The issue is that this isn't just a problem with DerivedProperties, but listener order in general through "intermediate states." Anytime there is a Multilink and you need to change two dependencies, it goes through an intermediate state.

  2. This is actually an issue with how Gas Properties' model has a listener order dependency. In the sim and normal user interface, it is impossible to trigger the wrong listener order (and in many cases assertions make sure of that). We can trigger a problem like ("lightParticles has not been populated yet") with ?shuffleListeners to demonstrate this issue. Note that this issue is also found without ?shuffleListeners when running in the state wrapper.

  3. PhetioStateEngine is an order of magnitude harder to solve that ?shuffleListeners because any Properties listeners can be called before any other. Let's say we have the following listener structure for Properties. A has a listeners that changes B, B has a listener that changes C, (i.e. A=>B=>C). ?shuffleListeners would still keep that ordering where A listeners fire before B and so on. But the state engine fires listeners in any order.

  4. What if we create a way to set order dependency in PhetioStateEngine? With not that much code we could add this relationship, and also make that happen automatically for DerivedProperties. Let's call this changeset "state set order dependencies."

    state set order dependencies

Index: axon/js/DerivedProperty.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- axon/js/DerivedProperty.js  (revision e9fa76fdf8f3e0a6fdb74e57eb5a97d495b34b5b)
+++ axon/js/DerivedProperty.js  (date 1586831785944)
@@ -63,6 +63,13 @@
         };
         self.dependencyListeners.push( listener );
         dependency.lazyLink( listener );
+
+        // register that for PhET-iO state, each dependency should be set (and therefore have its listeners called) before
+        // the DerivedProperty. NOTE: in practice right now all are undefered before calling the first dependency so the
+        // order in which listeners call will likely be: dependencyA, derivedProperty, dependencyB, derivedProperty, etc. . .
+        if ( Tandem.PHET_IO_ENABLED && this.isPhetioInstrumented() && dependency.isPhetioInstrumented() ) {
+          phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( dependency, this );
+        }
       } )( dependency, i );
     }
   }
Index: phet-io/js/PhetioStateEngine.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- phet-io/js/PhetioStateEngine.js (revision 470d0fd156f24c6c7c3cd7373616336c5ca5571a)
+++ phet-io/js/PhetioStateEngine.js (date 1586831785939)
@@ -53,6 +53,10 @@
     // @private {PhetioState|null} - keep track of the most recent state set to the sim. This is used to restore to
     // that state for the "reset all" case.
     this.mostRecentSavedState = null;
+
+    // {Object.<string,PhetioObject>} - where keys are the phetioIDs. For each key, every value must be set before the
+    // key can be set.
+    this.beforeMap = {};
   }

   /**
@@ -168,12 +172,15 @@
       let currentPhetioIDs = _.keys( state ).filter( key => key.indexOf( phetioIDPrefix ) === 0 );
       let passIndex = 0;

+      // {Object.<phetioID:string,boolean>} - a list of all phetioIDs that have successfully had their state set.
+      const completeMap = {};
+
       // Run as many passes as necessary to set all parts of the state.  This assumes all operations are commutative.
       while ( currentPhetioIDs.length > 0 ) {
         if ( passIndex > 5 ) {
           console.log( 'pass: ' + passIndex + ', keys.length = ' + currentPhetioIDs.length + ', keys = ' + currentPhetioIDs );
         }
-        currentPhetioIDs = this.iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter );
+        currentPhetioIDs = this.iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter, completeMap );
         passIndex++;
       }
     };
@@ -240,35 +247,42 @@
    * @returns {string[]} - list of phetioIDs for the next pass
    * @private
    */
-  iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter ) {
+  iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter, stateCompleteMap ) {
     let changed = false;
     const nextPass = [];

     currentPhetioIDs.forEach( phetioID => {
-      try {
-        let phetioObject = null;
+      if ( !this.isValid( stateCompleteMap, phetioID ) ) {
+        nextPass.push( phetioID );
+      }
+      else {
+
+        try {
+          let phetioObject = null;

-        // If we already have a reference to the PhetioObject
-        if ( this.phetioEngine.phetioObjectMap.hasOwnProperty( phetioID ) ) {
-          phetioObject = this.phetioEngine.getPhetioObject( phetioID );
-        }
-        else {
+          // If we already have a reference to the PhetioObject
+          if ( this.phetioEngine.phetioObjectMap.hasOwnProperty( phetioID ) ) {
+            phetioObject = this.phetioEngine.getPhetioObject( phetioID );
+          }
+          else {

-          // If we don't have a reference to the PhetioObject, create one. Could throw CouldNotYetDeserializeError.
-          phetioObject = this.create( state, phetioID );
-        }
+            // If we don't have a reference to the PhetioObject, create one. Could throw CouldNotYetDeserializeError.
+            phetioObject = this.create( state, phetioID );
+          }

-        // Could throw CouldNotYetDeserializeError
-        this.setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter );
-        changed = true;
-      }
-      catch( e ) {
-        if ( e instanceof CouldNotYetDeserializeError || e.message === 'CouldNotYetDeserializeError' ) {
+          // Could throw CouldNotYetDeserializeError
+          this.setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter );
+          stateCompleteMap[ phetioID ] = true;
+          changed = true;
+        }
+        catch( e ) {
+          if ( e instanceof CouldNotYetDeserializeError || e.message === 'CouldNotYetDeserializeError' ) {

-          // If the object couldn't be created (if a dependency didn't exist yet), then wait for the next pass.
-          nextPass.push( phetioID );
-        }
-        else { throw e; }
+            // If the object couldn't be created (if a dependency didn't exist yet), then wait for the next pass.
+            nextPass.push( phetioID );
+          }
+          else { throw e; }
+        }
       }
     } );

@@ -312,7 +326,10 @@
       // only do this if the PhetioObject is already not deferred
       if ( phetioObject.setDeferred && !phetioObject.isDeferred ) {
         phetioObject.setDeferred( true );
-        stateSetOnceEmitter.addListener( () => actions.push( phetioObject.setDeferred( false ) ) );
+        stateSetOnceEmitter.addListener( () => {
+          const potentialAction = phetioObject.setDeferred( false );
+          potentialAction && actions.push( potentialAction );
+        } );
       }
       type.setValue && type.setValue( phetioObject, value );
     }
@@ -427,6 +444,36 @@
     }
   }

+  /**
+   * TODO: rename
+   * A function that determines if a phetioID is valid and ready for state setting
+   * @private
+   *
+   * @param {Object.<phetioID:string,boolean>}completeMap
+   * @param phetioID
+   * @returns {boolean}
+   */
+  isValid( completeMap, phetioID ) {
+    if ( this.beforeMap[ phetioID ] ) {
+      return _.every( this.beforeMap[ phetioID ], dependency => completeMap[ dependency.tandem.phetioID ] );
+    }
+    return true;
+  }
+
+
+  /**
+   * Register that one PhetioObject must be set for state before another one
+   * @public
+   *
+   * @param {PhetioObject} firstPhetioObject - the object that must be set before the second
+   * @param {PhetioObject} secondPhetioObject
+   */
+  registerOrderDependency( firstPhetioObject, secondPhetioObject ) {
+    // TODO: assert both are instrumented
+    this.beforeMap[ secondPhetioObject.tandem.phetioID ] = [] || this.beforeMap[ secondPhetioObject.tandem.phetioID ];
+    this.beforeMap[ secondPhetioObject.tandem.phetioID ].push( firstPhetioObject );
+  }
+
   /**
    * Capture and emit initial state, and, if specified via query parameters, listen for simulation frames to emit
    * additional states. Should only be called once per instance.

  1. Then we find that we also have to specifically undefer in that order also. Take this for example:

    xHolderArray = [];
    yHolderArray = [];
    
    xNumberProperty = new Property( 2 )
    yNumberProperty = new Property( 1 )
    
    xNumberProperty.link( numberOfX => { xHolderArray = new Array( numberOfX)  )
    yNumberProperty.link( numberOfY => { yHolderArray = new Array( numberOfY)  )
    
    totalNumberProperty =  new DerivedProperty( [xNumberProperty, yNumberProperty], (x,y)=> x+y);
    totalNumberProperty.link(()=>{
      assert( xHolderArray.length === xNumberProperty.value );
      assert( yHolderArray.length === yNumberProperty.value );
    } );
    
    // constraints
    xNumberProperty and yNumberProperty must have the first listener called before totalNuberProperty listener gets called
    
    // solved by:
    undefer xNumberProperty
    undefer yNumberProperty
    notify xNumberProperty
    notify yNumberPropertyListeners
    undefer totalNumberProperty
    notify totalNubmerProperty
    

    Note that a primary way to solve this would be to instrument the arrays also. Then their values will already be correct by the time listeners first. In general, @samreid and I are finding most of the difficultly when listeners cause other Properties to change their value.

  2. Now that we have the patch defined in (4), go through GasProperties and try to list out the order dependencies. This didn't work very well! Either we run into the bug in (5), where one fix was to try to undefer based on the dependency order. Undeferring in batches (buggy!). Here is a case where that algorithm doesn't work:

    aProperty = new Property(2)
    bProperty = new Property(1)
    
    aProperty.link(a=>{
      assert( a>bProperty.value);
    })
    bProperty.link( b=>{
      assert( b<aProperty.value);
    })
    
    // constaints
    a.value must be right before calling b listeners
    b.value must be right before calling a listeners
    
    // solved by the original state set undeferring algorithm:
    undefer a
    undefer b
    notify a
    notify b
  3. After all that! I tried to just fix Gas Properties with the mentality that its model is imperfect because it contains listener orders (which is reasonable to think that many sims do!). Thus we can apply workarounds in the PhET-iO state set case such that the assertions trying to guard against listener order don't fire. This was my least favorite approach, and one that I'm not that interested in generally, but I want to note that after the below patch, which involved two state set guards, one Property instrumentation (plus making it reentrant:true), and one total hack (see ParticleSystem.addParticle), the sim successfully fuzzes without erroring in phet brand, phet-io brand, and state fuzzing (after multiple minutes of fuzz). I think we can do better on the temperature problem.

Gas Properties fixes ```diff Index: js/common/model/BaseContainer.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/common/model/BaseContainer.js (revision 4a76dcff69f7538d1fdad6f814714eaf2b88c547) +++ js/common/model/BaseContainer.js (date 1586831001400) @@ -15,6 +15,7 @@ import RangeWithValue from '../../../../dot/js/RangeWithValue.js'; import Vector2 from '../../../../dot/js/Vector2.js'; import merge from '../../../../phet-core/js/merge.js'; +import Tandem from '../../../../tandem/js/Tandem.js'; import gasProperties from '../../gasProperties.js'; import Particle from './Particle.js'; @@ -31,7 +32,10 @@ position: Vector2.ZERO, // range and initial value of the container's width, in pm - widthRange: new RangeWithValue( 5000, 15000, 10000 ) + widthRange: new RangeWithValue( 5000, 15000, 10000 ), + + // phet-io + tandem: Tandem.REQUIRED }, options ); @@ -47,7 +51,9 @@ // @public width of the container, in pm this.widthProperty = new NumberProperty( this.widthRange.defaultValue, { range: this.widthRange, - units: 'pm' + units: 'pm', + reentrant: true, // Occurring in PhET-iO State setting + tandem: options.tandem.createTandem( 'widthProperty' ) // instrument this because it is a dependency for lidWidthProperty in PhET-iO state } ); // @public (read-only) height of the container, in pm Index: js/diffusion/model/DiffusionContainer.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/diffusion/model/DiffusionContainer.js (revision 4a76dcff69f7538d1fdad6f814714eaf2b88c547) +++ js/diffusion/model/DiffusionContainer.js (date 1586829223886) @@ -26,14 +26,13 @@ constructor( options ) { options = merge( { + widthRange: new RangeWithValue( CONTAINER_WIDTH, CONTAINER_WIDTH, CONTAINER_WIDTH ), // effectively fixed width // phet-io tandem: Tandem.REQUIRED }, options ); - super( { - widthRange: new RangeWithValue( CONTAINER_WIDTH, CONTAINER_WIDTH, CONTAINER_WIDTH ) // effectively fixed width - } ); + super( options ); // In case clients attempt to use this feature of the base class this.widthProperty.lazyLink( width => { Index: js/common/model/IdealGasLawModel.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/common/model/IdealGasLawModel.js (revision 4a76dcff69f7538d1fdad6f814714eaf2b88c547) +++ js/common/model/IdealGasLawModel.js (date 1586832471621) @@ -220,7 +220,8 @@ } ); // Verify that we're not in a bad 'Hold Constant' state. - assert && this.holdConstantProperty.link( holdConstant => { + const assertHoldConstantForPressureAndParticleNumber = () => { + const holdConstant = this.holdConstantProperty.value; // values that are incompatible with an empty container assert && assert( !( this.particleSystem.numberOfParticlesProperty.value === 0 && @@ -234,7 +235,16 @@ ( holdConstant === HoldConstant.PRESSURE_V || holdConstant === HoldConstant.PRESSURE_T ) ), `bad holdConstant state: ${holdConstant} with pressure=${this.pressureModel.pressureProperty.value}` ); + }; + + + // This can happen each time the holdConstant changes, so long as we aren't setting PhET-iO state + assert && this.holdConstantProperty.link( holdConstant => { + !phet.joist.sim.isSettingPhetioStateProperty.value && assertHoldConstantForPressureAndParticleNumber(); } ); + + // Make sure that by the end of state setting, everything has solidified. + phet.joist.sim.isSettingPhetioStateProperty.lazyLink( settingState => !settingState && assertHoldConstantForPressureAndParticleNumber() ); } /** Index: js/common/model/ParticleSystem.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/common/model/ParticleSystem.js (revision 4a76dcff69f7538d1fdad6f814714eaf2b88c547) +++ js/common/model/ParticleSystem.js (date 1586829765627) @@ -96,18 +96,24 @@ 'lightParticles should contain only LightParticle' ); } ); - // @public N, the total number of particles in the container. - this.numberOfParticlesProperty = new DerivedProperty( - [ this.numberOfHeavyParticlesProperty, this.numberOfLightParticlesProperty ], - ( numberOfHeavyParticles, numberOfLightParticles ) => { - - // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. - // If you hit these assertions, then you need to add this listener later. This is a trade-off - // for using plain old Arrays instead of ObservableArray. - assert && assert( this.heavyParticles.length === numberOfHeavyParticles, - 'heavyParticles has not been populated yet' ); - assert && assert( this.lightParticles.length === numberOfLightParticles, - 'lightParticles has not been populated yet' ); + // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. + // If you hit these assertions, then you need to add this listener later. This is a trade-off + // for using plain old Arrays instead of ObservableArray. + // NOTE: This is factored out to support PhET-iO state and its independent order setting. + const assertListsMatchProperties = () => { + assert && assert( this.heavyParticles.length === this.numberOfHeavyParticlesProperty.value, + 'heavyParticles has not been populated yet' ); + assert && assert( this.lightParticles.length === this.numberOfLightParticlesProperty.value, + 'lightParticles has not been populated yet' ); + }; + + // @public N, the total number of particles in the container. + this.numberOfParticlesProperty = new DerivedProperty( + [ this.numberOfHeavyParticlesProperty, this.numberOfLightParticlesProperty ], + ( numberOfHeavyParticles, numberOfLightParticles ) => { + + // guard around PhET-iO state firing this listener in an intermediate state + !phet.joist.sim.isSettingPhetioStateProperty.value && assertListsMatchProperties(); return numberOfHeavyParticles + numberOfLightParticles; }, { phetioType: DerivedPropertyIO( NumberIO ), @@ -117,6 +123,10 @@ phetioDocumentation: 'the total number of particles in the container' } ); + + // Make sure that by the end of state setting, everything has solidified. + // TODO: Alternatively. It would work to instrument the particle arrays so that their length was corrected during state set. + phet.joist.sim.isSettingPhetioStateProperty.lazyLink( settingState => !settingState && assertListsMatchProperties() ); } /** @@ -248,7 +258,13 @@ assert && assert( typeof createParticle === 'function', `invalid createParticle: ${createParticle}` ); // Get the mean temperature that will be used to compute initial speed. - const meanTemperature = this.getInitialTemperature(); + let meanTemperature = this.getInitialTemperature(); + + // TODO: this is a total hack! My best guess here is that we are only in an intermediate value, and by the time + // all listeners from state set have been called this will resolve itself and recalculate + if( meanTemperature === 0 && phet.joist.sim.isSettingPhetioStateProperty.value){ + meanTemperature = 1E-6; + } assert && assert( meanTemperature > 0, `invalid meanTemperature: ${meanTemperature}` ); // Create n temperature values that will be used to compute initial speed. Index: js/phet-io/gas-properties-baseline.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- js/phet-io/gas-properties-baseline.js (revision 4a76dcff69f7538d1fdad6f814714eaf2b88c547) +++ js/phet-io/gas-properties-baseline.js (date 1586832259599) @@ -66,6 +66,19 @@ "phetioStudioControl": true, "phetioTypeName": "PropertyIO" }, + "gasProperties.diffusionScreen.model.container.widthProperty": { + "phetioDocumentation": "", + "phetioDynamicElement": false, + "phetioEventType": "MODEL", + "phetioFeatured": false, + "phetioHighFrequency": false, + "phetioIsArchetype": false, + "phetioPlayback": false, + "phetioReadOnly": false, + "phetioState": true, + "phetioStudioControl": true, + "phetioTypeName": "NumberPropertyIO" + }, "gasProperties.diffusionScreen.model.isPlayingProperty": { "phetioDocumentation": "", "phetioDynamicElement": false, @@ -5265,6 +5278,19 @@ "phetioState": true, "phetioStudioControl": false, "phetioTypeName": "NumberPropertyIO" + }, + "gasProperties.energyScreen.model.container.widthProperty": { + "phetioDocumentation": "", + "phetioDynamicElement": false, + "phetioEventType": "MODEL", + "phetioFeatured": false, + "phetioHighFrequency": false, + "phetioIsArchetype": false, + "phetioPlayback": false, + "phetioReadOnly": false, + "phetioState": true, + "phetioStudioControl": true, + "phetioTypeName": "NumberPropertyIO" }, "gasProperties.energyScreen.model.heatCoolFactorProperty": { "phetioDocumentation": "The amount of heat or cool applied to particles in the container. -1 is maximum cooling, +1 is maximum heat, 0 is off", @@ -12623,6 +12649,19 @@ "phetioState": true, "phetioStudioControl": false, "phetioTypeName": "NumberPropertyIO" + }, + "gasProperties.exploreScreen.model.container.widthProperty": { + "phetioDocumentation": "", + "phetioDynamicElement": false, + "phetioEventType": "MODEL", + "phetioFeatured": false, + "phetioHighFrequency": false, + "phetioIsArchetype": false, + "phetioPlayback": false, + "phetioReadOnly": false, + "phetioState": true, + "phetioStudioControl": true, + "phetioTypeName": "NumberPropertyIO" }, "gasProperties.exploreScreen.model.heatCoolFactorProperty": { "phetioDocumentation": "The amount of heat or cool applied to particles in the container. -1 is maximum cooling, +1 is maximum heat, 0 is off", @@ -21918,6 +21957,19 @@ "phetioState": true, "phetioStudioControl": false, "phetioTypeName": "NumberPropertyIO" + }, + "gasProperties.idealScreen.model.container.widthProperty": { + "phetioDocumentation": "", + "phetioDynamicElement": false, + "phetioEventType": "MODEL", + "phetioFeatured": false, + "phetioHighFrequency": false, + "phetioIsArchetype": false, + "phetioPlayback": false, + "phetioReadOnly": false, + "phetioState": true, + "phetioStudioControl": true, + "phetioTypeName": "NumberPropertyIO" }, "gasProperties.idealScreen.model.heatCoolFactorProperty": { "phetioDocumentation": "The amount of heat or cool applied to particles in the container. -1 is maximum cooling, +1 is maximum heat, 0 is off", ```

Note that no changes are needed in regards to (4) and the state engine for these gas properties changes.

If this was my sim, I would have committed parts of this, as it is at least 4 steps closer to a working state wrapper. Many of them may feel like workarounds, but I think that this point is arguable, since much of them are based on the fact that we are working with a model that contains listener order dependencies that we need to try to configure with a state engine that expects order independence.

It also isn't totally clear that declaring order dependencies to the state engine is the cleanest solution. At the very least it feels like duplicating the same logic in two spots. For examples:


const myProperty = new Property( 2 );
const myOtherProperty = new Property( 5 );

myProperty.link( value => {
  myOtherProeprty.value = calculateBasedOnOtherValue( value );
} );

myOtherProperty.link( ()=>{
  assert( iBetterBeCalledAfterTheAboveListener);
})

phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( myProperty, myOtherProperty );

I can see a future for the state engine code in DerivedProperty.js, but couldn't find any places where it fixed a problem.

Over to you @samreid for any questions.

pixelzoom commented 4 years ago

If this was my sim, I would have committed parts of this, as it is at least 4 steps closer to a working state wrapper.

Re the patch for (7) in https://github.com/phetsims/axon/issues/276#issuecomment-613198365... I added the suggested instrumentation of BaseContainer, after fixing some problems that the patch would have introduced (e.g. crashing the sim when chaining widthProperty in Studio). As for the other changes in the patch, I'm not at all a fan of phet.joist.sim.isSettingPhetioStateProperty -- it feels like a hack, a last resort, and I'd like to avoid that approach if at all possible. And its only necessary because:

Many of them may feel like workarounds, but I think that this point is arguable, since much of them are based on the fact that we are working with a model that contains listener order dependencies that we need to try to configure with a state engine that expects order independence.

PhET sims are not order independent. Basing a state engine based on that requirement is a mistake.

A reminder that ?shuffleListeners was intended to identify order dependencies so that they could be reworked if necessary, documented if not. It was not intended to eliminate order dependencies, nor has there ever been a goal to have total order independence. And as you said elsewhere in this comment, there are undoubtedly many sims that have some form of order dependency. (At least in this Gas Properties, they are documented and verified with assertions.) Furthermore, total order independence is (I would argue) unnecessary, undesirable, and in some cases so difficult as to be impractical/impossible.

A state engine that expects order independence is a bad match for PhET sims. It doesn't match the reality of how sims are implemented. And changing sims to match the needs of the state engine will be (and has been) not only expensive, but will also make it more difficult to write sims.

samreid commented 4 years ago

I investigated a new constraints strategy that gives more fine-grained control over when "undefer" and "notify" should happen relative to each other. It also removes assertions from one DerivedProperty derivation in gas-properties, with a note about why. That may be important in how this is working and I should test that in isolation. This is likely overcomplicated but it fuzzed for 30+ minutes on Gas Properties without an error. I wanted to store a record of it before I continue investigation.

```diff Index: main/gas-properties/js/common/model/IdealGasLawModel.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/gas-properties/js/common/model/IdealGasLawModel.js (revision 85303090bf0b8ccb7801d090a5ff0bc80579ceb5) +++ main/gas-properties/js/common/model/IdealGasLawModel.js (date 1586842944923) @@ -235,6 +235,9 @@ holdConstant === HoldConstant.PRESSURE_T ) ), `bad holdConstant state: ${holdConstant} with pressure=${this.pressureModel.pressureProperty.value}` ); } ); + + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.particleSystem.numberOfParticlesProperty, 'notify', this.holdConstantProperty, 'notify' ); + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.pressureModel.pressureProperty, 'notify', this.holdConstantProperty, 'notify' ); } /** Index: main/axon/js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/DerivedProperty.js (revision 0dc61d76aad1f6862d5fd3096431ad8e81e5461c) +++ main/axon/js/DerivedProperty.js (date 1586842586961) @@ -63,6 +63,26 @@ }; self.dependencyListeners.push( listener ); dependency.lazyLink( listener ); + + // register that for PhET-iO state, each dependency should be set (and therefore have its listeners called) before + // the DerivedProperty. NOTE: in practice right now all are undefered before calling the first dependency so the + // order in which listeners call will likely be: dependencyA, derivedProperty, dependencyB, derivedProperty, etc. . . + if ( Tandem.PHET_IO_ENABLED && this.isPhetioInstrumented() && dependency.isPhetioInstrumented() ) { + // phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( dependency, 'notify', this, 'undefer' ); + // phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( dependency, 'notify', this, 'undefer' ); + + // Don't let this dependency notify until all other dependencies have undefered + for (let k=0;k + // dependency should undefer before this listener is called + } } )( dependency, i ); } } Index: main/gas-properties/js/common/model/ParticleSystem.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/gas-properties/js/common/model/ParticleSystem.js (revision 85303090bf0b8ccb7801d090a5ff0bc80579ceb5) +++ main/gas-properties/js/common/model/ParticleSystem.js (date 1586843632979) @@ -85,12 +85,14 @@ // Synchronize particle counts and arrays. const createHeavyParticle = () => new HeavyParticle(); this.numberOfHeavyParticlesProperty.link( ( newValue, oldValue ) => { + console.log('callback for numberOfHeavyParticlesProperty') this.updateNumberOfParticles( newValue, oldValue, this.heavyParticles, createHeavyParticle ); assert && assert( GasPropertiesUtils.isArrayOf( this.heavyParticles, HeavyParticle ), 'heavyParticles should contain only HeavyParticle' ); } ); const createLightParticle = () => new LightParticle(); this.numberOfLightParticlesProperty.link( ( newValue, oldValue ) => { + console.log('callback for numberOfLightParticlesProperty') this.updateNumberOfParticles( newValue, oldValue, this.lightParticles, createLightParticle ); assert && assert( GasPropertiesUtils.isArrayOf( this.lightParticles, LightParticle ), 'lightParticles should contain only LightParticle' ); @@ -101,13 +103,8 @@ [ this.numberOfHeavyParticlesProperty, this.numberOfLightParticlesProperty ], ( numberOfHeavyParticles, numberOfLightParticles ) => { - // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. - // If you hit these assertions, then you need to add this listener later. This is a trade-off - // for using plain old Arrays instead of ObservableArray. - assert && assert( this.heavyParticles.length === numberOfHeavyParticles, - 'heavyParticles has not been populated yet' ); - assert && assert( this.lightParticles.length === numberOfLightParticles, - 'lightParticles has not been populated yet' ); + // no side effects or undeclared dependencies in derivations, because the derivatino is callead eagerly even though notifications are sent lazily (may be deferred) + return numberOfHeavyParticles + numberOfLightParticles; }, { phetioType: DerivedPropertyIO( NumberIO ), @@ -117,6 +114,27 @@ phetioDocumentation: 'the total number of particles in the container' } ); + + this.numberOfParticlesProperty.link(numberOfParticles=>{ + // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. + // If you hit these assertions, then you need to add this listener later. This is a trade-off + // for using plain old Arrays instead of ObservableArray. + assert && assert( this.heavyParticles.length === this.numberOfHeavyParticlesProperty.value, + 'heavyParticles has not been populated yet' ); + assert && assert( this.lightParticles.length === this.numberOfLightParticlesProperty.value, + 'lightParticles has not been populated yet' ); + }); + + // this.numberOfHeavyParticlesProperty listeners should go before this.numberOfParticlesProperty listeners + // this.numberOfLightParticlesProperty listeners should go before this.numberOfParticlesProperty listeners + + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.numberOfLightParticlesProperty, 'undefer', this.numberOfParticlesProperty, 'undefer' ); + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.numberOfLightParticlesProperty, 'notify', this.numberOfParticlesProperty, 'undefer' ); + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.numberOfHeavyParticlesProperty, 'undefer', this.numberOfParticlesProperty, 'undefer' ); + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.numberOfHeavyParticlesProperty, 'notify', this.numberOfParticlesProperty, 'undefer' ); + + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.numberOfLightParticlesProperty, 'undefer', this.numberOfHeavyParticlesProperty, 'notify' ); + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( this.numberOfHeavyParticlesProperty, 'undefer', this.numberOfLightParticlesProperty, 'notify' ); } /** Index: main/assert/js/assert.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/assert/js/assert.js (revision a99a9ce9ee0774bf3ef509bf3e31ca7383f67478) +++ main/assert/js/assert.js (date 1586836615338) @@ -19,6 +19,7 @@ const logMessage = message ? 'Assertion failed: ' + message : 'Assertion failed'; console && console.log && console.log( logMessage ); + debugger; throw new Error( logMessage ); } }; Index: main/phet-io/js/PhetioStateEngine.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/phet-io/js/PhetioStateEngine.js (revision 470d0fd156f24c6c7c3cd7373616336c5ca5571a) +++ main/phet-io/js/PhetioStateEngine.js (date 1586842128562) @@ -10,6 +10,7 @@ import BooleanProperty from '../../axon/js/BooleanProperty.js'; import Emitter from '../../axon/js/Emitter.js'; +import arrayRemove from '../../phet-core/js/arrayRemove.js'; import CouldNotYetDeserializeError from '../../tandem/js/CouldNotYetDeserializeError.js'; import EventType from '../../tandem/js/EventType.js'; import Tandem from '../../tandem/js/Tandem.js'; @@ -53,6 +54,10 @@ // @private {PhetioState|null} - keep track of the most recent state set to the sim. This is used to restore to // that state for the "reset all" case. this.mostRecentSavedState = null; + + // {Object.} - where keys are the phetioIDs. For each key, every value must be set before the + // key can be set. + this.constraints = []; } /** @@ -144,6 +149,8 @@ * @param {string} [phetioIDPrefix] - if provided, state will only be set on phetioID subtrees of this prefix. */ setState( state, phetioIDPrefix = '' ) { + + console.log( '\n\nset state:' ); this.isSettingStateProperty.value = true; assert && assert( state !== undefined ); assert && assert( ( typeof state ) !== 'string' ); @@ -157,7 +164,7 @@ // For elements that support setDeferred(), we defer setting their values so all changes take effect at once. // Keep track of finalization actions that must take place after all values have changed. - const actions = []; + const notifyList = []; // clear out all the dynamic state so it can be set fresh by the new state. this.clearState( state, phetioIDPrefix ); @@ -173,7 +180,7 @@ if ( passIndex > 5 ) { console.log( 'pass: ' + passIndex + ', keys.length = ' + currentPhetioIDs.length + ', keys = ' + currentPhetioIDs ); } - currentPhetioIDs = this.iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter ); + currentPhetioIDs = this.iterate( currentPhetioIDs, state, notifyList, stateSetOnceEmitter ); passIndex++; } }; @@ -184,8 +191,87 @@ // This emitter will toggle all that support it to be no longer deferred. Do this to every object // before calling listeners in the `actions` - stateSetOnceEmitter.emit(); - actions.forEach( action => action && action() ); + // stateSetOnceEmitter.emit(); + const undeferList = stateSetOnceEmitter.tinyEmitter.listeners; + + const completed = []; + + const isComplete = ( phetioID, action ) => { + for ( let i = 0; i < completed.length; i++ ) { + if ( completed[ i ].phetioID === phetioID && completed[ i ].thing === action ) { + return true; + } + } + return false; + }; + + const canProceed = ( phetioID, action ) => { + + // check the constraints to see if this has to be after something that is incomplete. + // all must pass + for ( let i = 0; i < this.constraints.length; i++ ) { + if ( this.constraints[ i ][ 2 ] === phetioID && this.constraints[ i ][ 3 ] === action ) { + const beforePhetioID = this.constraints[ i ][ 0 ]; + const beforeAction = this.constraints[ i ][ 1 ]; + if ( !isComplete( beforePhetioID, beforeAction ) ) { + return false; + } + } + } + return true; + }; + + let count = 0; + + // Normally we would like to undefer things before notify, but check the constraints. + while ( undeferList.length > 0 || notifyList.length > 0 ) { + count++; + + if (count>5000){ + debugger; + } + + let undeferred = false; + // see if something can be undeferred + for ( let i = 0; i < undeferList.length; i++ ) { + const undefer = undeferList[ i ]; + + // check the constraints to see if this has to be after something that is incomplete. + if ( canProceed( undefer.phetioID, 'undefer' ) ) { + console.log( 'undefer', undefer.phetioID ); + undefer(); + arrayRemove( undeferList, undefer ); + completed.push( { phetioID: undefer.phetioID, thing: 'undefer' } ); + + undeferred = true; + break; // reset index + } + } + + if ( undeferred ) { + // great, go to next iteration and try to undefer something else + } + else { + + // try to notify + + for ( let i = 0; i < notifyList.length; i++ ) { + const notify = notifyList[ i ]; + + // check the constraints to see if this has to be after something that is incomplete. + if ( canProceed( notify.phetioID, 'notify' ) ) { + console.log( 'notify', notify.phetioID ); + notify(); + arrayRemove( notifyList, notify ); + completed.push( { phetioID: notify.phetioID, thing: 'notify' } ); + + break; // try to undefer something else + } + } + } + } + // undeferList.forEach( undefer => undefer() ); + // notifyList.forEach( notify => notify() ); // This is before updating the views to allow for more custom state-setting logic to be notified here. this.stateSetEmitter.emit( state ); @@ -235,12 +321,12 @@ * If there were keys to set and nothing happened, throw an assertion error. * @param {string[]} currentPhetioIDs * @param {PhetioState} state - the entire save state of the entire sim - * @param {function[]} actions + * @param {function[]} notifyList * @param {Emitter} stateSetOnceEmitter * @returns {string[]} - list of phetioIDs for the next pass * @private */ - iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter ) { + iterate( currentPhetioIDs, state, notifyList, stateSetOnceEmitter ) { let changed = false; const nextPass = []; @@ -259,7 +345,7 @@ } // Could throw CouldNotYetDeserializeError - this.setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter ); + this.setStateForPhetioObject( phetioObject, state, notifyList, stateSetOnceEmitter ); changed = true; } catch( e ) { @@ -291,29 +377,35 @@ * Given an existing PhetioObject, set its value. * @param {PhetioObject} phetioObject * @param {PhetioState} state - the entire save state of the entire sim - * @param {function[]} actions + * @param {function[]} notifyList * @param {Emitter} stateSetOnceEmitter * @throws CouldNotYetDeserializeError * @private */ - setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter ) { + setStateForPhetioObject( phetioObject, state, notifyList, stateSetOnceEmitter ) { const phetioID = phetioObject.tandem.phetioID; const type = this.phetioEngine.getPhetioType( phetioID ); assert && assert( !!type.fromStateObject, 'fromStateObject should be defined for ' + type.typeName ); - // Don't try to set values for DerivedProperty instances, see https://github.com/phetsims/phet-io/issues/1292 - if ( type.typeName.indexOf( 'DerivedPropertyIO' ) !== 0 ) { - - const value = type.fromStateObject( state[ phetioID ] ); - const phetioObject = this.phetioEngine.getPhetioObject( phetioID ); + const value = type.fromStateObject( state[ phetioID ] ); + // const phetioObject = this.phetioEngine.getPhetioObject( phetioID ); - // withhold notifications until all values have been set to avoid inconsistent intermediate states, - // see https://github.com/phetsims/phet-io-wrappers/issues/229 - // only do this if the PhetioObject is already not deferred - if ( phetioObject.setDeferred && !phetioObject.isDeferred ) { - phetioObject.setDeferred( true ); - stateSetOnceEmitter.addListener( () => actions.push( phetioObject.setDeferred( false ) ) ); - } + // withhold notifications until all values have been set to avoid inconsistent intermediate states, + // see https://github.com/phetsims/phet-io-wrappers/issues/229 + // only do this if the PhetioObject is already not deferred + if ( phetioObject.setDeferred && !phetioObject.isDeferred ) { + phetioObject.setDeferred( true ); + const listener = () => { + const potentialAction = phetioObject.setDeferred( false ) || function(){}; + potentialAction.phetioID = phetioID; + notifyList.push( potentialAction ); + }; + listener.phetioID = phetioID; + stateSetOnceEmitter.addListener( listener ); + } + + // Don't try to set values for DerivedProperty instances, see https://github.com/phetsims/phet-io/issues/1292 + if ( type.typeName.indexOf( 'DerivedPropertyIO' ) !== 0 ) { type.setValue && type.setValue( phetioObject, value ); } } @@ -427,6 +519,17 @@ } } + /** + * Register that one PhetioObject must be set for state before another one + * @public + * + * @param {PhetioObject} firstPhetioObject - the object that must be set before the second + * @param {PhetioObject} secondPhetioObject + */ + registerOrderDependency( firstPhetioObject, firstTerm, secondPhetioObject, secondTerm ) { + this.constraints.push( [ firstPhetioObject.tandem.phetioID, firstTerm, secondPhetioObject.tandem.phetioID, secondTerm ] ); + } + /** * Capture and emit initial state, and, if specified via query parameters, listen for simulation frames to emit * additional states. Should only be called once per instance. ```

UPDATE: Eventually I did hit a fuzzing error in Gas Properties here, but I noticed this fuzz error is in the source:

image

samreid commented 4 years ago

This patch is essentially a cleaned up version of the prior comment. It's fuzzing well in Gas Properties and Projectile Motion.

```diff Index: main/tandem/js/Tandem.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/tandem/js/Tandem.js (revision ae2e9548994a866a4c5dd114154de0baba77179b) +++ main/tandem/js/Tandem.js (date 1586894374613) @@ -429,6 +429,21 @@ */ Tandem.PHET_IO_ENABLED = PHET_IO_ENABLED; +/** + * Register that one PhetioObject must be set for state before another one + * @public + * + * @param {PhetioObject} beforePhetioObject - the object that must be set before the second + * @param {string} beforeTerm 'undefer'|'notify' + * @param {PhetioObject} afterPhetioObject + * @param {string} afterTerm 'undefer'|'notify' + */ +Tandem.registerOrderDependency = (beforePhetioObject,beforeAction,afterPhetioObject,afterAction)=>{ + if ( Tandem.PHET_IO_ENABLED && beforePhetioObject.isPhetioInstrumented() && afterPhetioObject.isPhetioInstrumented() ) { + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( beforePhetioObject, beforeAction, afterPhetioObject, afterAction ); + } +}; + /** * Group Tandem -- Declared in the same file to avoid circular reference errors in module loading. * TODO: Replace GroupTandem usages with DynamicTandem, see https://github.com/phetsims/tandem/issues/87 and https://github.com/phetsims/phet-io/issues/1409 Index: main/gas-properties/js/common/model/IdealGasLawModel.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/gas-properties/js/common/model/IdealGasLawModel.js (revision 85303090bf0b8ccb7801d090a5ff0bc80579ceb5) +++ main/gas-properties/js/common/model/IdealGasLawModel.js (date 1586894400168) @@ -235,6 +235,10 @@ holdConstant === HoldConstant.PRESSURE_T ) ), `bad holdConstant state: ${holdConstant} with pressure=${this.pressureModel.pressureProperty.value}` ); } ); + + // Make sure the values in the holdConstantProperty have the correct values before calling the holdConstantProperty listeners + Tandem.registerOrderDependency( this.particleSystem.numberOfParticlesProperty, 'notify', this.holdConstantProperty, 'notify' ); + Tandem.registerOrderDependency( this.pressureModel.pressureProperty, 'notify', this.holdConstantProperty, 'notify' ); } /** Index: main/axon/js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/axon/js/DerivedProperty.js (revision 0dc61d76aad1f6862d5fd3096431ad8e81e5461c) +++ main/axon/js/DerivedProperty.js (date 1586894374619) @@ -63,6 +63,19 @@ }; self.dependencyListeners.push( listener ); dependency.lazyLink( listener ); + + // Dependencies should notify before the DerivedProperty undefers, so it will be sure to have the right value. + Tandem.registerOrderDependency( dependency, 'notify', this, 'undefer' ); + + // Don't let this dependency notify until all other dependencies have undeferred. When applying this rule to + // all dependencies, this is equivalent to saying that all dependencies must undefer before any dependency notifies + // SR: I'm not 100% sure about this one. + for ( let k = 0; k < dependencies.length; k++ ) { + if ( k !== i ) { + Tandem.registerOrderDependency( dependencies[ k ], 'undefer', dependency, 'notify' ); + } + } + } )( dependency, i ); } } Index: main/gas-properties/js/common/model/ParticleSystem.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/gas-properties/js/common/model/ParticleSystem.js (revision 85303090bf0b8ccb7801d090a5ff0bc80579ceb5) +++ main/gas-properties/js/common/model/ParticleSystem.js (date 1586893813835) @@ -101,13 +101,10 @@ [ this.numberOfHeavyParticlesProperty, this.numberOfLightParticlesProperty ], ( numberOfHeavyParticles, numberOfLightParticles ) => { - // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. - // If you hit these assertions, then you need to add this listener later. This is a trade-off - // for using plain old Arrays instead of ObservableArray. - assert && assert( this.heavyParticles.length === numberOfHeavyParticles, - 'heavyParticles has not been populated yet' ); - assert && assert( this.lightParticles.length === numberOfLightParticles, - 'lightParticles has not been populated yet' ); + // Side effects or undeclared dependencies should not be used in derivations, because even though the + // DerivedProperty notifications are made lazily (when deferred), then derivation function is always called eagerly. + // Hence I've moved the assertions to a link which can be called when the state "settles" + return numberOfHeavyParticles + numberOfLightParticles; }, { phetioType: DerivedPropertyIO( NumberIO ), @@ -117,6 +114,17 @@ phetioDocumentation: 'the total number of particles in the container' } ); + + assert && this.numberOfParticlesProperty.link( () => { + + // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. + // If you hit these assertions, then you need to add this listener later. This is a trade-off + // for using plain old Arrays instead of ObservableArray. + assert && assert( this.heavyParticles.length === this.numberOfHeavyParticlesProperty.value, + 'heavyParticles has not been populated yet' ); + assert && assert( this.lightParticles.length === this.numberOfLightParticlesProperty.value, + 'lightParticles has not been populated yet' ); + } ); } /** Index: main/phet-io/js/PhetioStateEngine.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- main/phet-io/js/PhetioStateEngine.js (revision 470d0fd156f24c6c7c3cd7373616336c5ca5571a) +++ main/phet-io/js/PhetioStateEngine.js (date 1586894882299) @@ -10,6 +10,7 @@ import BooleanProperty from '../../axon/js/BooleanProperty.js'; import Emitter from '../../axon/js/Emitter.js'; +import arrayRemove from '../../phet-core/js/arrayRemove.js'; import CouldNotYetDeserializeError from '../../tandem/js/CouldNotYetDeserializeError.js'; import EventType from '../../tandem/js/EventType.js'; import Tandem from '../../tandem/js/Tandem.js'; @@ -53,6 +54,9 @@ // @private {PhetioState|null} - keep track of the most recent state set to the sim. This is used to restore to // that state for the "reset all" case. this.mostRecentSavedState = null; + + // {Object[][]} - Each entry has [beforePhetioID,beforeAction,afterPhetioID,afterAction] where each action is 'undefer' or 'notify' + this.constraints = []; } /** @@ -144,6 +148,7 @@ * @param {string} [phetioIDPrefix] - if provided, state will only be set on phetioID subtrees of this prefix. */ setState( state, phetioIDPrefix = '' ) { + this.isSettingStateProperty.value = true; assert && assert( state !== undefined ); assert && assert( ( typeof state ) !== 'string' ); @@ -157,7 +162,7 @@ // For elements that support setDeferred(), we defer setting their values so all changes take effect at once. // Keep track of finalization actions that must take place after all values have changed. - const actions = []; + const notifyList = []; // clear out all the dynamic state so it can be set fresh by the new state. this.clearState( state, phetioIDPrefix ); @@ -173,7 +178,7 @@ if ( passIndex > 5 ) { console.log( 'pass: ' + passIndex + ', keys.length = ' + currentPhetioIDs.length + ', keys = ' + currentPhetioIDs ); } - currentPhetioIDs = this.iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter ); + currentPhetioIDs = this.iterate( currentPhetioIDs, state, notifyList, stateSetOnceEmitter ); passIndex++; } }; @@ -184,8 +189,94 @@ // This emitter will toggle all that support it to be no longer deferred. Do this to every object // before calling listeners in the `actions` - stateSetOnceEmitter.emit(); - actions.forEach( action => action && action() ); + // stateSetOnceEmitter.emit(); + const undeferList = stateSetOnceEmitter.tinyEmitter.listeners; + + const completed = []; + + const isComplete = ( phetioID, action ) => { + for ( let i = 0; i < completed.length; i++ ) { + if ( completed[ i ].phetioID === phetioID && completed[ i ].thing === action ) { + return true; + } + } + return false; + }; + + const canProceed = ( phetioID, action ) => { + + // Undefer must happen before notify + if ( action === 'notify' ) { + if ( !isComplete( phetioID, 'undefer' ) ) { + return false; + } + } + + // check the constraints to see if this has to be after something that is incomplete. + // all must pass + for ( let i = 0; i < this.constraints.length; i++ ) { + if ( this.constraints[ i ][ 2 ] === phetioID && this.constraints[ i ][ 3 ] === action ) { + const beforePhetioID = this.constraints[ i ][ 0 ]; + const beforeAction = this.constraints[ i ][ 1 ]; + if ( !isComplete( beforePhetioID, beforeAction ) ) { + return false; + } + } + } + return true; + }; + + let count = 0; + + // Normally we would like to undefer things before notify, but make sure this is done in accordance with the constraints. + while ( undeferList.length > 0 || notifyList.length > 0 ) { + count++; + + if ( count > 5000 ) { + throw new Error( 'Ordering constraints cannot be satisfied' ); + } + + let undeferred = false; + + // see if something can be undeferred + for ( let i = 0; i < undeferList.length; i++ ) { + const undefer = undeferList[ i ]; + + // check the constraints to see if this has to be after something that is incomplete. + if ( canProceed( undefer.phetioID, 'undefer' ) ) { + // console.log( 'undefer', undefer.phetioID ); + undefer(); + arrayRemove( undeferList, undefer ); + completed.push( { phetioID: undefer.phetioID, thing: 'undefer' } ); + + undeferred = true; + break; // reset index + } + } + + if ( undeferred ) { + + // great, go to next iteration and try to undefer something else + } + else { + + // try to notify + + for ( let i = 0; i < notifyList.length; i++ ) { + const notify = notifyList[ i ]; + + // check the constraints to see if this has to be after something that is incomplete. + if ( canProceed( notify.phetioID, 'notify' ) ) { + // console.log( 'notify', notify.phetioID ); + notify(); + arrayRemove( notifyList, notify ); + completed.push( { phetioID: notify.phetioID, thing: 'notify' } ); + + break; // try to undefer something else + } + } + } + } // This is before updating the views to allow for more custom state-setting logic to be notified here. this.stateSetEmitter.emit( state ); @@ -235,12 +326,12 @@ * If there were keys to set and nothing happened, throw an assertion error. * @param {string[]} currentPhetioIDs * @param {PhetioState} state - the entire save state of the entire sim - * @param {function[]} actions + * @param {function[]} notifyList * @param {Emitter} stateSetOnceEmitter * @returns {string[]} - list of phetioIDs for the next pass * @private */ - iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter ) { + iterate( currentPhetioIDs, state, notifyList, stateSetOnceEmitter ) { let changed = false; const nextPass = []; @@ -259,7 +350,7 @@ } // Could throw CouldNotYetDeserializeError - this.setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter ); + this.setStateForPhetioObject( phetioObject, state, notifyList, stateSetOnceEmitter ); changed = true; } catch( e ) { @@ -291,29 +382,36 @@ * Given an existing PhetioObject, set its value. * @param {PhetioObject} phetioObject * @param {PhetioState} state - the entire save state of the entire sim - * @param {function[]} actions + * @param {function[]} notifyList * @param {Emitter} stateSetOnceEmitter * @throws CouldNotYetDeserializeError * @private */ - setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter ) { + setStateForPhetioObject( phetioObject, state, notifyList, stateSetOnceEmitter ) { const phetioID = phetioObject.tandem.phetioID; const type = this.phetioEngine.getPhetioType( phetioID ); assert && assert( !!type.fromStateObject, 'fromStateObject should be defined for ' + type.typeName ); - // Don't try to set values for DerivedProperty instances, see https://github.com/phetsims/phet-io/issues/1292 - if ( type.typeName.indexOf( 'DerivedPropertyIO' ) !== 0 ) { - - const value = type.fromStateObject( state[ phetioID ] ); - const phetioObject = this.phetioEngine.getPhetioObject( phetioID ); + const value = type.fromStateObject( state[ phetioID ] ); - // withhold notifications until all values have been set to avoid inconsistent intermediate states, - // see https://github.com/phetsims/phet-io-wrappers/issues/229 - // only do this if the PhetioObject is already not deferred - if ( phetioObject.setDeferred && !phetioObject.isDeferred ) { - phetioObject.setDeferred( true ); - stateSetOnceEmitter.addListener( () => actions.push( phetioObject.setDeferred( false ) ) ); - } + // withhold notifications until all values have been set to avoid inconsistent intermediate states, + // see https://github.com/phetsims/phet-io-wrappers/issues/229 + // only do this if the PhetioObject is already not deferred + if ( phetioObject.setDeferred && !phetioObject.isDeferred ) { + phetioObject.setDeferred( true ); + const listener = () => { + + // Always add a function so that we can track the order dependency + const potentialAction = phetioObject.setDeferred( false ) || function() {}; + potentialAction.phetioID = phetioID; + notifyList.push( potentialAction ); + }; + listener.phetioID = phetioID; + stateSetOnceEmitter.addListener( listener ); + } + + // Don't try to set values for DerivedProperty instances, see https://github.com/phetsims/phet-io/issues/1292 + if ( type.typeName.indexOf( 'DerivedPropertyIO' ) !== 0 ) { type.setValue && type.setValue( phetioObject, value ); } } @@ -427,6 +525,21 @@ } } + /** + * Register that one PhetioObject must be set for state before another one + * @public + * + * @param {PhetioObject} firstPhetioObject - the object that must be set before the second + * @param {string} firstAction 'undefer'|'notify' + * @param {PhetioObject} secondPhetioObject + * @param {string} secondAction 'undefer'|'notify' + */ + registerOrderDependency( firstPhetioObject, firstAction, secondPhetioObject, secondAction ) { + assert && assert( firstAction === 'undefer' || firstAction === 'notify', 'should be undefer|notify' ); + assert && assert( secondAction === 'undefer' || secondAction === 'notify', 'should be undefer|notify' ); + this.constraints.push( [ firstPhetioObject.tandem.phetioID, firstAction, secondPhetioObject.tandem.phetioID, secondAction ] ); + } + /** * Capture and emit initial state, and, if specified via query parameters, listen for simulation frames to emit * additional states. Should only be called once per instance. ```

The main idea from the patch is splitting out the 2 primary phases of Property notification.

The patch introduces an API call like the one in https://github.com/phetsims/axon/issues/276#issuecomment-613198365 but with the ability to specify relative order dependence between undefer and notify.

For example, I added the two bottom lines from this snippet in IdealGasLawModel:

    // Verify that we're not in a bad 'Hold Constant' state.
    assert && this.holdConstantProperty.link( holdConstant => {

      // values that are incompatible with an empty container
      assert && assert( !( this.particleSystem.numberOfParticlesProperty.value === 0 &&
      ( holdConstant === HoldConstant.TEMPERATURE ||
        holdConstant === HoldConstant.PRESSURE_T ||
        holdConstant === HoldConstant.PRESSURE_V ) ),
        `bad holdConstant state: ${holdConstant} with numberOfParticles=${this.particleSystem.numberOfParticlesProperty.value}` );

      // values that are incompatible with zero pressure
      assert && assert( !( this.pressureModel.pressureProperty.value === 0 &&
      ( holdConstant === HoldConstant.PRESSURE_V ||
        holdConstant === HoldConstant.PRESSURE_T ) ),
        `bad holdConstant state: ${holdConstant} with pressure=${this.pressureModel.pressureProperty.value}` );
    } );

    // Make sure the values in the holdConstantProperty have the correct values before calling the holdConstantProperty listeners
    Tandem.registerOrderDependency( this.particleSystem.numberOfParticlesProperty, 'notify', this.holdConstantProperty, 'notify' );
    Tandem.registerOrderDependency( this.pressureModel.pressureProperty, 'notify', this.holdConstantProperty, 'notify' );

And DerivedProperty uses this to make sure that (a) Dependencies should notify before the DerivedProperty undefers, so it will be sure to have the right value. (b) All dependencies should undefer before any of them notify (I'm not 100% sure this one is necessary since DerivedProperty caches dependency values and will still go through thrashing)

Next step is to discuss with @zepumph and @pixelzoom.

pixelzoom commented 4 years ago

Looks promising, looking forward to discussing. I realize that the above patch is a proposal, but a few things about the API for the discussion agenda:

samreid commented 4 years ago

I agree it will be nice to unify terminology and use Enumeration. The behavior in master is to do all undefer as a batch in arbitrary order, then do all notify as a batch in arbitrary order.

stateSetOnceEmitter.emit(); // <-- does all of the undefers
actions.forEach( action => action && action() ); // <-- does all the notifies

The patch enforces that undefer must happen before notify

      // Undefer must happen before notify
      if ( action === 'notify' ) {
        if ( !isComplete( phetioID, 'undefer' ) ) {
          return false;
        }
      }

and that specified constraints are met, otherwise the order is arbitrary.

pixelzoom commented 4 years ago

Thanks for clarifying. Sounds like the next step is a discussion. @samreid will you schedule?

zepumph commented 4 years ago

@pixelzoom, and @samreid and I discussed this issue more today. Then @samreid and I worked further on the above patch. This is as far as I got, though I thought it was close to commit (as it is working in gas properties), there is an order dependency infinite loop in ph-scale, so here is more of a patch:

```diff Index: axon/js/Property.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- axon/js/Property.js (revision 62f66e35ed1f064fdccf37fa93b5bc50e0289b6a) +++ axon/js/Property.js (date 1587512667011) @@ -7,6 +7,7 @@ * @author Chris Malley (PixelZoom, Inc.) */ +import Enumeration from '../../phet-core/js/Enumeration.js'; import merge from '../../phet-core/js/merge.js'; import PhetioObject from '../../tandem/js/PhetioObject.js'; import Tandem from '../../tandem/js/Tandem.js'; @@ -338,14 +339,37 @@ // @public set value( newValue ) { this.set( newValue ); } + /** + * This function registers an order dependency between this Property and another. Basically this says that when + * setting PhET-iO state, each dependency must take its final value before this Property fires its notifications. + * See Property.registerOrderDependency and https://github.com/phetsims/axon/issues/276 for more info. + * TODO: add a deregistrations, https://github.com/phetsims/axon/issues/276 + * @param {Property[]} dependencies + * @private + */ + addPhetioDependencies( dependencies ) { + assert && assert( Array.isArray( dependencies ), 'Array expected' ); + for ( let i = 0; i < dependencies.length; i++ ) { + const dependency = dependencies[ i ]; + + // The dependency should undefer (taking deferred value) before this Property notifies. + Property.registerOrderDependency( dependency, Property.Phase.UNDEFER, this, Property.Phase.NOTIFY ); + } + } + /** * Adds listener and calls it immediately. If listener is already registered, this is a no-op. The initial * notification provides the current value for newValue and null for oldValue. * * @param {function} listener a function of the form listener(newValue,oldValue) + * @param {Object} [options] * @public */ - link( listener ) { + link( listener, options ) { + if ( options && options.phetioDependencies ) { + this.addPhetioDependencies( options.phetioDependencies ); + } + this.changedEmitter.addListener( listener ); listener( this.get(), null, this ); // null should be used when an object is expected but unavailable } @@ -354,9 +378,13 @@ * Add an listener to the Property, without calling it back right away. This is used when you need to register a * listener without an immediate callback. * @param {function} listener - a function with a single argument, which is the current value of the Property. + * @param {Object} [options] * @public */ - lazyLink( listener ) { + lazyLink( listener, options ) { + if ( options && options.phetioDependencies ) { + this.addPhetioDependencies( options.phetioDependencies ); + } this.changedEmitter.addListener( listener ); } @@ -495,10 +523,30 @@ static unmultilink( multilink ) { multilink.dispose(); } + + /** + * Register that one Property must be set for state before another one + * @public + * + * @param {Property} beforeProperty - the object that must be set before the second + * @param {Property.Phase} beforePhase + * @param {Property} afterProperty + * @param {Property.Phase} afterPhase + */ + static registerOrderDependency( beforeProperty, beforePhase, afterProperty, afterPhase ) { + assert && assert( Property.Phase.includes( beforePhase ) && Property.Phase.includes( afterPhase ), 'unexpected phase' ); + + if ( Tandem.PHET_IO_ENABLED && beforeProperty.isPhetioInstrumented() && afterProperty.isPhetioInstrumented() ) { + phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( beforeProperty, beforePhase, afterProperty, afterPhase ); + } + } } // static attributes Property.CHANGED_EVENT_NAME = 'changed'; +// @public {Enumeration} - describes different phases a Property can go through in its value setting and notification lifecycle. +Property.Phase = Enumeration.byKeys( [ 'UNDEFER', 'NOTIFY' ] ); + axon.register( 'Property', Property ); export default Property; \ No newline at end of file Index: assert/js/assert.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- assert/js/assert.js (revision a99a9ce9ee0774bf3ef509bf3e31ca7383f67478) +++ assert/js/assert.js (date 1587516543221) @@ -19,7 +19,7 @@ const logMessage = message ? 'Assertion failed: ' + message : 'Assertion failed'; console && console.log && console.log( logMessage ); - throw new Error( logMessage ); +debugger; throw new Error( logMessage ); } }; Index: phet-io/js/phetioCommandProcessor.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- phet-io/js/phetioCommandProcessor.js (revision ace756bf6166c06064c5a1c2fabeb912d3cfc464) +++ phet-io/js/phetioCommandProcessor.js (date 1587514388104) @@ -160,7 +160,7 @@ return { returnValue: this.processCommand( targetWindow, command ), error: null }; } catch( error ) { - +debugger; // Catch error during invocation and return them to the caller, and take serializable parts. return { returnValue: null, error: { message: error.message, stack: error.stack } }; } Index: axon/js/Multilink.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- axon/js/Multilink.js (revision 62f66e35ed1f064fdccf37fa93b5bc50e0289b6a) +++ axon/js/Multilink.js (date 1587508019189) @@ -39,7 +39,13 @@ } }; self.dependencyListeners.push( listener ); - dependency.lazyLink( listener ); + dependency.lazyLink( listener, { + + // All other dependencies should undefer (taking deferred value) before this dependency notifies. This is + // crucial to prevent this Multilink callback from firing with intermediate (buggy) states before all dependencies + // have taken their final value. + phetioDependencies: _.without( dependencies, dependency ) + } ); } ); // Send initial call back but only if we are non-lazy Index: axon/js/DerivedProperty.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- axon/js/DerivedProperty.js (revision 62f66e35ed1f064fdccf37fa93b5bc50e0289b6a) +++ axon/js/DerivedProperty.js (date 1587517005427) @@ -52,6 +52,8 @@ const self = this; + this.derivation = derivation; + // @private Keep track of listeners so they can be detached this.dependencyListeners = []; @@ -59,10 +61,26 @@ const dependency = dependencies[ i ]; ( dependency => { const listener = () => { - super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); + + // TODO: as a separate commit, have derivedProperty support deferred values better + if ( this.isDeferred ) { + this.hasDeferredValue = true; + } + else { + super.set( derivation.apply( null, dependencies.map( property => property.get() ) ) ); + } }; self.dependencyListeners.push( listener ); - dependency.lazyLink( listener ); + + dependency.lazyLink( listener, { + + // All other dependencies should undefer (taking deferred value) before this dependency notifies. This is + // crucial because one of the listeners to be notified is this DerivedProperty's derivation function. + phetioDependencies: _.without( dependencies, dependency ) + } ); + + // Dependencies should notify before the DerivedProperty undefers, so it will be sure to have the right value. + Property.registerOrderDependency( dependency, Property.Phase.NOTIFY, this, Property.Phase.UNDEFER ); } )( dependency, i ); } } @@ -119,6 +137,13 @@ */ getInitialValue() { throw new Error( 'Cannot get the initial value of a DerivedProperty' ); } + setDeferred( isDeferred ) { + if ( this.isDeferred && !isDeferred ) { + this.deferredValue = this.derivation.apply( null, this.dependencies.map( property => property.get() ) ); + } + super.setDeferred( isDeferred ); + } + /** * Override the getter for value as well, since we need the getter/setter pair to override the getter/setter pair in Property * (instead of a setter with no getter overriding). See https://github.com/phetsims/axon/issues/171 for more details Index: gas-properties/js/common/model/IdealGasLawModel.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- gas-properties/js/common/model/IdealGasLawModel.js (revision 604c104e4e757eaab5f81b91df1b38cdcc87a4f2) +++ gas-properties/js/common/model/IdealGasLawModel.js (date 1587505620034) @@ -234,6 +234,11 @@ ( holdConstant === HoldConstant.PRESSURE_V || holdConstant === HoldConstant.PRESSURE_T ) ), `bad holdConstant state: ${holdConstant} with pressure=${this.pressureModel.pressureProperty.value}` ); + }, { + phetioDependencies: [ + this.particleSystem.numberOfParticlesProperty, + this.pressureModel.pressureProperty + ] } ); } Index: phet-io/js/PhetioStateEngine.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- phet-io/js/PhetioStateEngine.js (revision ace756bf6166c06064c5a1c2fabeb912d3cfc464) +++ phet-io/js/PhetioStateEngine.js (date 1587516600876) @@ -10,6 +10,8 @@ import BooleanProperty from '../../axon/js/BooleanProperty.js'; import Emitter from '../../axon/js/Emitter.js'; +import Property from '../../axon/js/Property.js'; +import arrayRemove from '../../phet-core/js/arrayRemove.js'; import CouldNotYetDeserializeError from '../../tandem/js/CouldNotYetDeserializeError.js'; import EventType from '../../tandem/js/EventType.js'; import Tandem from '../../tandem/js/Tandem.js'; @@ -53,6 +55,9 @@ // @private {PhetioState|null} - keep track of the most recent state set to the sim. This is used to restore to // that state for the "reset all" case. this.mostRecentSavedState = null; + + // {Object[][]} - Each entry has [beforePhetioID,beforePhase,afterPhetioID,afterPhase] where each action is 'undefer' or 'notify' + this.constraints = []; } /** @@ -144,16 +149,17 @@ * @param {string} [phetioIDPrefix] - if provided, state will only be set on phetioID subtrees of this prefix. */ setState( state, phetioIDPrefix = '' ) { + this.isSettingStateProperty.value = true; assert && assert( state !== undefined ); assert && assert( ( typeof state ) !== 'string' ); - // emitter to notify when a transaction has ended - const stateSetOnceEmitter = new Emitter(); - // For elements that support setDeferred(), we defer setting their values so all changes take effect at once. // Keep track of finalization actions that must take place after all values have changed. - const actions = []; + // {PhaseCallback[]} + // TODO: could these lists be combined if PhaseCallback kept the phase as a field? would that speed things up or slow things down? + const notifyList = []; + const undeferList = []; // clear out all the dynamic state so it can be set fresh by the new state. this.clearState( state, phetioIDPrefix ); @@ -167,14 +173,11 @@ if ( passIndex > 5 ) { console.log( 'pass: ' + passIndex + ', keys.length = ' + currentPhetioIDs.length + ', keys = ' + currentPhetioIDs ); } - currentPhetioIDs = this.iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter ); + currentPhetioIDs = this.iterate( currentPhetioIDs, state, undeferList, notifyList ); passIndex++; } - // This emitter will toggle all that support it to be no longer deferred. Do this to every object - // before calling listeners in the `actions` - stateSetOnceEmitter.emit(); - actions.forEach( action => action && action() ); + this.undeferAndNotifyProperties( undeferList, notifyList ); // This is before updating the views to allow for more custom state-setting logic to be notified here. this.stateSetEmitter.emit( state ); @@ -187,6 +190,112 @@ this.isSettingStateProperty.value = false; } + /** + * TODO + * + * Performance enhancement ideas: + * `completed` can be a clone of the constraints list and mutate a completed flag? + * @private + * @param {PhaseCallback[]} undeferList + * @param {PhaseCallback[]} notifyList + */ + undeferAndNotifyProperties( undeferList, notifyList ) { + + // {phetioID:string, phase:Property.Phase} - list of phetioIDs that have had a phase completed. + // TODO: type case + const completedPhases = []; + + /** + * @param {string} phetioID + * @param {Property.Phase} phase + * @returns {boolean} - if the phase for a given PhET-iO element has already been completed ("applied"). + */ + const phetioIDHasCompletedPhase = ( phetioID, phase ) => { + for ( let i = 0; i < completedPhases.length; i++ ) { + if ( completedPhases[ i ].phetioID === phetioID && completedPhases[ i ].phase === phase ) { + return true; + } + } + return false; + }; + + /** + * @param {string} phetioID + * @param {Property.Phase} phase + * @returns {boolean} - if the provided phase can be applied given the dependency constraints of the state engine. + */ + const phetioIDCanApplyPhase = ( phetioID, phase ) => { + + // Undefer must happen before notify + if ( phase === Property.Phase.NOTIFY ) { + if ( !phetioIDHasCompletedPhase( phetioID, Property.Phase.UNDEFER ) ) { + return false; + } + } + + // check the constraints to see if this has to be after something that is incomplete. + // all must pass + for ( let i = 0; i < this.constraints.length; i++ ) { + if ( this.constraints[ i ][ 2 ] === phetioID && this.constraints[ i ][ 3 ] === phase ) { + const beforePhetioID = this.constraints[ i ][ 0 ]; + const beforePhase = this.constraints[ i ][ 1 ]; + if ( !phetioIDHasCompletedPhase( beforePhetioID, beforePhase ) ) { + return false; + } + } + } + return true; + }; + + // to support failing out instead of inifinite loop + let numberOfIterations = 0; + + // Normally we would like to undefer things before notify, but make sure this is done in accordance with the constraints. + while ( undeferList.length > 0 || notifyList.length > 0 ) { + numberOfIterations++; + + // TODO: is there a better check than a number of iterations? + this.errorImpossibleSetState( numberOfIterations > 5000, ' from undeferAndNotifyProperties; ordering constaints cannot be satisfied' ); + + let somethingWasUndeferred = false; + + // see if something can be undeferred + for ( let i = 0; i < undeferList.length; i++ ) { + const undeferPhaseCallback = undeferList[ i ]; + + // check the constraints to see if this has to be after something that is incomplete. + if ( phetioIDCanApplyPhase( undeferPhaseCallback.phetioID, Property.Phase.UNDEFER ) ) { + undeferPhaseCallback.listener(); + arrayRemove( undeferList, undeferPhaseCallback ); + completedPhases.push( { phetioID: undeferPhaseCallback.phetioID, phase: Property.Phase.UNDEFER } ); + + somethingWasUndeferred = true; + break; // reset index + } + } + + if ( somethingWasUndeferred ) { + // great, go to next iteration and try to undefer something else + } + else { + // try to notify + + for ( let i = 0; i < notifyList.length; i++ ) { + const notifyPhaseCallback = notifyList[ i ]; + + // check the constraints to see if this has to be after something that is incomplete. + if ( phetioIDCanApplyPhase( notifyPhaseCallback.phetioID, Property.Phase.NOTIFY ) ) { + notifyPhaseCallback.listener(); + arrayRemove( notifyList, notifyPhaseCallback ); + completedPhases.push( { phetioID: notifyPhaseCallback.phetioID, phase: Property.Phase.NOTIFY } ); + + break; // try to undefer something else in the next iteration + } + } + } + } + } + /** * Update the views of the sim. This is meant to run after the state has been set to make sure that all view * elements are in sync with the new, current state of the sim. (even when the sim is inactive, as in the state @@ -224,12 +333,12 @@ * If there were keys to set and nothing happened, throw an assertion error. * @param {string[]} currentPhetioIDs * @param {PhetioState} state - the entire save state of the entire sim - * @param {function[]} actions - * @param {Emitter} stateSetOnceEmitter + * @param {PhaseCallback[]} undeferList + * @param {PhaseCallback[]} notifyList * @returns {string[]} - list of phetioIDs for the next pass * @private */ - iterate( currentPhetioIDs, state, actions, stateSetOnceEmitter ) { + iterate( currentPhetioIDs, state, undeferList, notifyList ) { let changed = false; const nextPass = []; @@ -248,7 +357,7 @@ } // Could throw CouldNotYetDeserializeError - this.setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter ); + this.setStateForPhetioObject( phetioObject, state, undeferList, notifyList ); changed = true; } catch( e ) { @@ -266,43 +375,58 @@ return nextPass; } - assert && assert( changed, 'Impossible set state:\n' + nextPass.join( '\n' ) ); + this.errorImpossibleSetState( !changed, `\n from iterate:\n ${nextPass.join( '\\n' )}` ); + + return nextPass; + } + + /** + * @private + * @param {boolean} failed - if we failed with an impossibleSetState + * @param {string} message - supplemental content + */ + errorImpossibleSetState( failed, message ) { + assert && assert( !failed, `Impossible set state: ${message}` ); // We must exit here even if assertions are disabled so it wouldn't lock up the browser. - if ( !changed && !assert ) { - throw new Error( 'Impossible set state:\n' + nextPass.join( '\n' ) ); + if ( failed && !assert ) { + throw new Error( `Impossible set state: ${message}` ); } - - return nextPass; } /** * Given an existing PhetioObject, set its value. * @param {PhetioObject} phetioObject * @param {PhetioState} state - the entire save state of the entire sim - * @param {function[]} actions - * @param {Emitter} stateSetOnceEmitter + * @param {PhaseCallback[]} undeferList + * @param {PhaseCallback[]} notifyList * @throws CouldNotYetDeserializeError * @private */ - setStateForPhetioObject( phetioObject, state, actions, stateSetOnceEmitter ) { + setStateForPhetioObject( phetioObject, state, undeferList, notifyList ) { const phetioID = phetioObject.tandem.phetioID; const type = this.phetioEngine.getPhetioType( phetioID ); assert && assert( !!type.fromStateObject, 'fromStateObject should be defined for ' + type.typeName ); - // Don't try to set values for DerivedProperty instances, see https://github.com/phetsims/phet-io/issues/1292 - if ( type.typeName.indexOf( 'DerivedPropertyIO' ) !== 0 ) { - - const value = type.fromStateObject( state[ phetioID ] ); - const phetioObject = this.phetioEngine.getPhetioObject( phetioID ); + const value = type.fromStateObject( state[ phetioID ] ); - // withhold notifications until all values have been set to avoid inconsistent intermediate states, - // see https://github.com/phetsims/phet-io-wrappers/issues/229 - // only do this if the PhetioObject is already not deferred - if ( phetioObject.setDeferred && !phetioObject.isDeferred ) { - phetioObject.setDeferred( true ); - stateSetOnceEmitter.addListener( () => actions.push( phetioObject.setDeferred( false ) ) ); - } + // withhold AXON/Property notifications until all values have been set to avoid inconsistent intermediate states, + // see https://github.com/phetsims/phet-io-wrappers/issues/229 + // only do this if the PhetioObject is already not deferred + if ( phetioObject instanceof Property && !phetioObject.isDeferred ) { + phetioObject.setDeferred( true ); + const listener = () => { + + // Always add a function so that we can track the order dependency + // TODO: always create a function?!?! + const potentialPhase = phetioObject.setDeferred( false ); + notifyList.push( new PhaseCallback( phetioID, potentialPhase ) ); + }; + undeferList.push( new PhaseCallback( phetioID, listener ) ); + } + + // Don't try to set values for DerivedProperty instances, see https://github.com/phetsims/phet-io/issues/1292 + if ( type.typeName.indexOf( 'DerivedPropertyIO' ) !== 0 ) { type.setValue && type.setValue( phetioObject, value ); } } @@ -416,6 +540,20 @@ } } + /** + * Register that one PhetioObject must be set for state before another one + * @public + * + * @param {Property} firstProperty - the object that must be set before the second + * @param {Property.Phase} firstPhase + * @param {Property} secondProperty + * @param {Property.Phase} secondPhase + */ + registerOrderDependency( firstProperty, firstPhase, secondProperty, secondPhase ) { + assert && assert( Property.Phase.includes( firstPhase ) && Property.Phase.includes( secondPhase ), 'unexpected phase' ); + this.constraints.push( [ firstProperty.tandem.phetioID, firstPhase, secondProperty.tandem.phetioID, secondPhase ] ); + } + /** * Capture and emit initial state, and, if specified via query parameters, listen for simulation frames to emit * additional states. Should only be called once per instance. @@ -457,5 +595,18 @@ } } +// @private +class PhaseCallback { + + /** + * @param {string} phetioID + * @param {function|null} listener + */ + constructor( phetioID, listener ) { + this.phetioID = phetioID; + this.listener = listener || _.noop; + } +} + phetio.register( 'PhetioStateEngine', PhetioStateEngine ); export default PhetioStateEngine; \ No newline at end of file ```

Here is a summary of the meeting notes and further work, much of which was added in the patch:

Tools in the toolbox for phet-io state

  1. Recognize order dependencies
  2. Try to remove them in the sim (or codify them with multilink/derivedProperty)
  3. Annotate with phet-io state specific dependency registrations
  4. remove logic only on state (with something like "if not settingState" )

RE this list above: It is purposefully ordered from ideal to least ideal. In gas properties for https://github.com/phetsims/gas-properties/issues/182 we were had to go with (3) because it didn't make sense enough to alter the sim code itself, but it was much preferred over linking to the setting state Property.

I will keep working with @samreid.

zepumph commented 4 years ago

After some investigation into the ph-scale problem with @samreid, I logged the directionality of the order dependencies into an online graph visualization tool. This is what came up.

Note that we lose the granularity of 'notify' vs 'undefer', but it helps us reevaluate if these automatic order depedency registrations are actually possible to add generally

image

zepumph commented 4 years ago

@samreid had the great idea of looking at each phase/id combo as a node. Look at the marked doubled arrowed edge. That seems buggy:

image

Here is the debug code added to make this happen:

     const stillTODOIDs = undeferList.concat( notifyList ).map( item => item.phetioID )
          .filter( id => id.startsWith( 'phScale.microScreen.model.solution' ) )
        const relevantConstraints = this.constraints.filter( constraint => {
          return stillTODOIDs.indexOf( constraint[ 0 ] ) >= 0 ||
                 stillTODOIDs.indexOf( constraint[ 2 ] ) >= 0;
        } );
        // console.log( relevantConstraints );
        // relevantConstraints.forEach( t => console.log( t[ 0 ], t[ 1 ].name, '\t\t\t', t[ 2 ], t[ 3 ].name ) );
        let string = '';
        const map = x => x.replace( 'phScale.microScreen.model.solution.', '' );
        relevantConstraints.forEach( t => { string += `${map( t[ 0 ] )}${t[ 1 ].name}\t${map( t[ 2 ] )}${t[ 3 ].name}\n`; } );

        console.log( string );
zepumph commented 4 years ago

Now @samreid and I are having fun. Here is an image of the sim once we remove the buggy dependency registration between all of a DerivedProperty's dependencies:

image

Then we set up a way to just view derived Property dependencies outside the context of PhET-iO state. Here is a picture of this relationship in Ph-scale (so cool!)

image

We are happy to find that the ph-scale bug is fixed by removing dependency registration between all of a derivedProperty's dependencies. This isn't needed especially after we add better support for deferring DerivedProperty. Commits coming soon.

UPDATE: thanks https://csacademy.com/app/graph_editor/

zepumph commented 4 years ago

I ended up committing the code because things were looking very promising! We now have a more explicit way of detecting order dependency issues in the state engine.

Summary of changes:

There are a few TODOs that need to be done at a high priority:

@samreid please review the commits, and the above TODOs at a high priority.

pixelzoom commented 4 years ago

There are a bunch of new Errors in CT, "Impossible set state: from undeferAndNotifyProperties; ordering constaints cannot be satisfied". Are they related to these changes?

For example:

molarity : phet-io-state-fuzz : require.js : run
https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io-wrappers/state/?sim=molarity&phetioDebug&fuzz&postMessageToParent&postMessageOnLoad&postMessageOnError&postMessageOnBeforeUnload
Uncaught Error: Assertion failed: Impossible set state:  from undeferAndNotifyProperties; ordering constaints cannot be satisfied
Error: Assertion failed: Impossible set state:  from undeferAndNotifyProperties; ordering constaints cannot be satisfied
    at window.assertions.assertFunction (https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/assert/js/assert.js:22:13)
    at PhetioStateEngine.errorImpossibleSetState (https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/PhetioStateEngine.js:391:15)
    at PhetioStateEngine.undeferAndNotifyProperties (https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/PhetioStateEngine.js:276:12)
    at PhetioStateEngine.setState (https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/PhetioStateEngine.js:182:10)
    at PhetioEngineIO.implementation (https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/PhetioEngineIO.js:200:43)
    at PhetioCommandProcessor.processCommand (https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/phetioCommandProcessor.js:266:51)
    at https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/phetioCommandProcessor.js:160:36
    at Array.map (<anonymous>)
    at PhetioCommandProcessor.processCommands (https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/phetioCommandProcessor.js:158:30)
    at https://bayes.colorado.edu/continuous-testing/snapshot-1587622935351/phet-io/js/phetioCommandProcessor.js:99:16
id: Bayes Chrome
Approximately 4/23/2020, 12:22:15 AM
zepumph commented 4 years ago

This caused many errors as @pixelzoom mentioned above, which I'm surprised to see and bummed since before committing I fuzzed 4 state wrappers (just none that had this trouble). Here is the list of sims to handle:

pixelzoom commented 4 years ago

Sorry to hear that this created new problems. Also note that this did not fix one of the Gas Properties problems, and created a new variation on that problem, see https://github.com/phetsims/gas-properties/issues/178#issuecomment-618476464

zepumph commented 4 years ago

Here are the problemed constraints for EFAC (blocks) and RIAW (sound related): image

image

zepumph commented 4 years ago

For efac the problem seems to be that things are waiting on archetypes to get state set first. We should update that so that we don't add constraints about archetypes.

zepumph commented 4 years ago

Above I am ignoring dependencies that try to be registered on dynamic element archetypes. This fixed the EFAC bug. I don't like how I am doing it though, so I added a TODO to this issue.

zepumph commented 4 years ago

For sims that have sound enabled in them, there is a constraint placed on Sim.activeProperty. In the state wrapper this Property is removed from state in the downstream sim so that it is never made active. This meant that a constraint that was dependent on the activeProperty would never be fulfilled/completed. The above makes sure that the state engine only cares about the constraints that apply to the state that is actually being passed in. This will also solve (untested) problems with setting state on the reset all button, because that only takes a certain subtree of state and sets that.

After the above commit, MAL and RIAW are fixed, I'm not sure what other sims this applied to, as state tests have not been run on CT in some hours. I'll keep working my way down the above list.

zepumph commented 4 years ago

@chrisklus helped me track down that actually all the above sims were just a single bug. The issue was that there was an order dependency registered about a phetioID that wasn't set in state.

In EFAC that was a beakerGroup archetype associated with a multilink in BeakerContainerView.

In all other sims it was because soundManager.enabledProperty and browserTabVisibleProperty depends on the sim.activeProperty, (via a multilink in soundManager). But in the state wrapper we strip out the activeProperty from the state so that it doesn't change the downstream sim.

The solution to both is to just ignore any registered order dependency if both phetioIDs in it are not being set in the state for that setState call. The code was written at the top of undeferAndNotifyProperties and seems to be working well! I'm excited to check back after full runs tomorrow to make sure things are working on CT as expected.

zepumph commented 4 years ago

We also were then able to fix https://github.com/phetsims/gas-properties/issues/178, and I explained to @chrisklus about the deferred logic changes to DerivedProperty.

We are excited with the current state of things. I'm looking forward to seeing if all bugs are squashed on CT tonight, and will check back in the morning.

There are still checkboxes from https://github.com/phetsims/axon/issues/276#issuecomment-618106191 to discuss, and @chrisklus and I really want to do some more performance modifications. There are many nested loops currently, and it would be nice to have this as fast as possible.

chrisklus commented 4 years ago

Some in-progress changes for unregistering order depenencies and getting some tests going that @zepumph and I started this morning:

```diff Index: axon/js/Property.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- axon/js/Property.js (revision ea5e695775bf4d41a345ce09ffa075612c002dfe) +++ axon/js/Property.js (date 1587749981000) @@ -469,6 +469,11 @@ // remove any listeners that are still attached to this property this.unlinkAll(); + // unregister any order dependencies for this property from the PhetioStateEngine + if ( Tandem.PHET_IO_ENABLED && this.isPhetioInstrumented() ) { + phet.phetio.phetioEngine.phetioStateEngine.unregisterOrderDependenciesForProperty( this ); + } + super.dispose(); this.changedEmitter.dispose(); } @@ -540,7 +545,7 @@ assert && assert( Property.Phase.includes( beforePhase ) && Property.Phase.includes( afterPhase ), 'unexpected phase' ); if ( Tandem.PHET_IO_ENABLED && beforeProperty.isPhetioInstrumented() && afterProperty.isPhetioInstrumented() ) { - phet.phetio.phetioEngine.phetioStateEngine.registerOrderDependency( beforeProperty, beforePhase, afterProperty, afterPhase ); + phet.phetio.phetioEngine.phetioStateEngine.registerPropertyOrderDependency( beforeProperty, beforePhase, afterProperty, afterPhase ); } } } Index: phet-io/js/PhetioStateEngineTests.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- phet-io/js/PhetioStateEngineTests.js (date 1587751993000) +++ phet-io/js/PhetioStateEngineTests.js (date 1587751993000) @@ -0,0 +1,51 @@ +// Copyright 2020, University of Colorado Boulder + +/** + * TODO. + * + * @author Michael Kauzmann (PhET Interactive Simulations) + * @author Chris Klusendorf (PhET Interactive Simulations) + */ + +import BooleanProperty from '../../axon/js/BooleanProperty.js'; +import Property from '../../axon/js/Property.js'; +import Tandem from '../../tandem/js/Tandem.js'; +import PhetioStateEngine from './PhetioStateEngine.js'; + +// property order dependency registeation +// + +assert && assert( Tandem.PHET_IO_ENABLED, 'phet-io must be enabled for PhetioStateEngineTests tests' ); + +QUnit.module( 'PhetioStateEngineTests' ); + +// These tests run in brand=phet-io +QUnit.test( 'Register and unregister order dependency', assert => { + const phetioStateEngine = new PhetioStateEngine(); + + const propertyA = new BooleanProperty( false, { + tandem: Tandem.GENERAL.createTandem( 'propertyA' ) + } ); + const propertyB = new BooleanProperty( true, { + tandem: Tandem.GENERAL.createTandem( 'propertyB' ) + } ); + const propertyC = new BooleanProperty( false, { + tandem: Tandem.GENERAL.createTandem( 'propertyC' ) + } ); + + phetioStateEngine.registerPropertyOrderDependency( propertyA, Property.Phase.UNDEFER, propertyB, Property.Phase.NOTIFY ); + phetioStateEngine.registerPropertyOrderDependency( propertyB, Property.Phase.UNDEFER, propertyC, Property.Phase.NOTIFY ); + + // test order dependencies for a, b, and c + + phetioStateEngine.unregisterOrderDependenciesForProperty( propertyA ); + + // test that a is gone + // test order dependencies for b and c + + phetioStateEngine.unregisterOrderDependenciesForProperty( propertyB ); + + // test that there are no order dependencies? + + phetioStateEngine.unregisterOrderDependenciesForProperty( propertyC ); +} ); \ No newline at end of file Index: phet-io/js/PhetioStateEngine.js IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- phet-io/js/PhetioStateEngine.js (revision 95572f4033edd5e16428cc05797fb38911e20d6e) +++ phet-io/js/PhetioStateEngine.js (date 1587768765000) @@ -273,7 +273,6 @@ for ( let i = 0; i < phaseCallbacks.length; i++ ) { const phaseCallbackToPotentiallyApply = phaseCallbacks[ i ]; - let phaseCallbackWasApplied = false; // phaseCallbacks includes all phases, only try to // check the order dependencies to see if this has to be after something that is incomplete. @@ -290,10 +289,6 @@ // Keep track of all completed PhaseCallbacks completedPhases[ phaseCallbackToPotentiallyApply.getTerm() ] = true; - phaseCallbackWasApplied = true; - } - - if ( phaseCallbackWasApplied ) { i--; // Account for the element that was just removed by decrementing. } } @@ -593,14 +588,34 @@ * @param {Property} secondProperty * @param {Property.Phase} secondPhase */ - registerOrderDependency( firstProperty, firstPhase, secondProperty, secondPhase ) { - assert && assert( firstProperty instanceof Property ); - assert && assert( secondProperty instanceof Property ); - assert && assert( Property.Phase.includes( firstPhase ) && Property.Phase.includes( secondPhase ), 'unexpected phase' ); + registerPropertyOrderDependency( firstProperty, firstPhase, secondProperty, secondPhase ) { + const validatePropertyPhasePair = ( property, phase ) => { + assert && assert( property instanceof Property && property.isPhetioInstrumented(), `must be an instrumented Property: ${property}` ); + assert && assert( Property.Phase.includes( phase ), `unexpected phase: ${phase}` ); + }; + validatePropertyPhasePair( firstProperty, firstPhase ); + validatePropertyPhasePair( secondProperty, secondPhase ); this.propertyOrderDependencies.push( new OrderDependency( firstProperty.tandem.phetioID, firstPhase, secondProperty.tandem.phetioID, secondPhase ) ); } + /** + * Unregisters all order dependencies for the given Property + * + * @param {Property} property + * @public + */ + unregisterOrderDependenciesForProperty( property ) { + for ( let i = 0; i < this.propertyOrderDependencies.length; i++ ) { + const propertyOrderDependency = this.propertyOrderDependencies[ i ]; + + if ( propertyOrderDependency.usesPhetioID( property.tandem.phetioID ) ) { + arrayRemove( this.propertyOrderDependencies, propertyOrderDependency ); + i--; + } + } + } + /** * Capture and emit initial state, and, if specified via query parameters, listen for simulation frames to emit * additional states. Should only be called once per instance. @@ -667,7 +682,7 @@ } } -// POJSO for an order dependency. See PhetioStateEngine.registerOrderDependency +// POJSO for an order dependency. See PhetioStateEngine.registerPropertyOrderDependency class OrderDependency { /** ```
zepumph commented 4 years ago

All checkboxes have been done above to a basic extent. Here are the final TODOs:

zepumph commented 4 years ago

I did some refactoring, and doc improvements. This includes separating out sub functions into their own methods. We did that for setState methods like iterate and setStateForObject and it worked well, so I think it should be done here.

In https://github.com/phetsims/phet-io/commit/0275c6f1b7baabe1ceef7f50bb282473b3c6d319 I changed the algorithm a bit. I eagerly removed any PhaseCallback instances that don't have a listener. This likely isn't much of an improvement in many cases, but generally it felt good to never have anything waiting on a noop function to be called as an order dependency.

@chrisklus can you please take a look at the above changes, and then take one more pass through this new code. Let me know if any other potential improvements expose themselves to you. I'm happy to talk through anything else before close.

chrisklus commented 4 years ago

Thanks @zepumph! Agreed that eagerly removing any with null listeners is better than assigning them noop functions.

I added arguments and docs to errorInUndeferAndNotifyStep in the commit above. Everything else is looking good to me, back to you.

chrisklus commented 4 years ago

I accidentally committed something I was using to test my fix in https://github.com/phetsims/phet-io/commit/ea11e64f3c9b0e0afe75ea6c106d83d4dea3725a and it broke phet-io sims on CT, reverted above.

zepumph commented 4 years ago

Thanks @zepumph! Agreed that eagerly removing any with null listeners is better than assigning them noop functions.

Is the following use case problematic?

otherProperty -> property1 -> property2 -> property3.

Note here that property3 implicitly has an order dependency on property1. Now let's say that property2 has a null listener. This means that property2 will be completed immediately, potentially before property1, and thus property3 may complete a phase before property1.

The question I have is, "if this case is buggy because property3 really should come after property1, then shouldn't property3 declare that as an order dependency?"

We could call it, and just make a decision to favor clarity and readability, removing the short circuit for no-op listeners. That could save us a potential gotcha in the future. That said it feels like it could be a significant speed up (though I haven't profiled it).

Any more thoughts?

samreid commented 4 years ago

Why was the short-circuit for no-op listeners introduced/proposed? In the example in the preceding comment, it seems like property3 should really come after property1. And property3 shouldn't have to declare an explicit dependency on property1, it should happen implicitly through the transitive relationship via property2.

UPDATE: I now see the explanation in https://github.com/phetsims/axon/issues/276#issuecomment-622167980. But I can't tell if that rule prevents the transitive behavior we described.

chrisklus commented 4 years ago

Can we set up a test to determine if that case is problematic? It seems like two possible next steps for this issue are that (to see if the short-circuit is bug-free), or to profile some use cases during setState to see if there is a noticeable performance increase when using the short-circuit. I'm up to pair on this.

zepumph commented 4 years ago

I think the best way forward is to bring back the safer logic, and continue on with other issues. If speed ends up being a problem for the state engine, I believe that other solutions with have a better cost/benefit. For example https://github.com/phetsims/phet-io/issues/1658.

Bringing it back now unless there are objections.

zepumph commented 4 years ago

Implemented above. Anything else for this issue?

samreid commented 4 years ago

I think this is ready to close.

zepumph commented 4 years ago

I'd love a final glance by @chrisklus if he thinks it needs it. Anything else here? Did we fully optimize the new code?

zepumph commented 4 years ago

Please note that this is not the end of this issue. We supported Property well here, but Emitters and ObservableArray listeners are still on the table in https://github.com/phetsims/phet-io/issues/1634 and https://github.com/phetsims/phet-io/issues/1661 respectively. I'm excited to pick that up over there!

chrisklus commented 4 years ago

I think it's looking good. Any possible optimizations I see feel minor and insignificant. Looking forward to getting the other implementations going. Ready to close!

zepumph commented 4 years ago

Reopening, I found a TODO in Property:

* TODO: add a deregistrations, https://github.com/phetsims/axon/issues/276