%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /www/varak.net/wiki.varak.net/extensions/CirrusSearch/includes/Search/
Upload File :
Create Path :
Current File : /www/varak.net/wiki.varak.net/extensions/CirrusSearch/includes/Search/RescoreBuilders.php

<?php

namespace CirrusSearch\Search;

use CirrusSearch\Query\GeoFeature;
use CirrusSearch\Util;
use Elastica\Query\FunctionScore;
use Elastica\Query\AbstractQuery;
use MWNamespace;

/**
 * Set of rescore builders
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 */

/**
 * Builds a rescore queries by reading a rescore profile.
 */
class RescoreBuilder {
	/**
	 * List of allowed rescore params
	 * @todo: refactor to const with php 5.6
	 *
	 * @var string[] $rescoreMainParams
	 */
	private static $rescoreMainParams = [
		'query_weight',
		'rescore_query_weight',
		'score_mode'
	];

	const FUNCTION_SCORE_TYPE = "function_score";

	/**
	 * @var SearchContext
	 */
	private $context;

	/**
	 * @var array|string a rescore profile
	 */
	private $profile;

	/**
	 * @param SearchContext $context
	 * @param string|null $profile
	 */
	public function __construct( SearchContext $context, $profile = null ) {
		$this->context = $context;
		if ( $profile === null ) {
			$profile = $context->getRescoreProfile();
		}
		if ( is_string( $profile ) ) {
			$profile = $this->context->getConfig()->getElement( 'CirrusSearchRescoreProfiles', $profile );
		}
		$this->profile = $this->getSupportedProfile( $profile );
	}

	/**
	 * @return array of rescore queries
	 */
	public function build() {
		$rescores = [];
		foreach( $this->profile['rescore'] as $rescoreDef ) {
			$windowSize = $this->windowSize( $rescoreDef );
			$rescore = [
				'window_size' => $windowSize,
			];

			$rescore['query'] = array_intersect_key( $rescoreDef, array_flip( self::$rescoreMainParams ) );
			$rescoreQuery = $this->buildRescoreQuery( $rescoreDef );
			if ( $rescoreQuery === null ) {
				continue;
			}
			$rescore['query']['rescore_query'] = $rescoreQuery;
			$rescores[] = $rescore;
		}
		return $rescores;
	}

	/**
	 * builds the 'query' attribute by reading type
	 *
	 * @param array $rescoreDef
	 * @return FunctionScore|null the rescore query
	 */
	private function buildRescoreQuery( array $rescoreDef ) {
		switch( $rescoreDef['type'] ) {
		case self::FUNCTION_SCORE_TYPE:
			$funcChain = new FunctionScoreChain( $this->context, $rescoreDef['function_chain'] );
			return $funcChain->buildRescoreQuery();
		default: throw new InvalidRescoreProfileException( "Unsupported rescore query type: " . $rescoreDef['type'] );
		}
	}

	/**
	 * @param array $rescore
	 * @return integer the window size defined in the profile
	 * or the value from config if window_size_override is set.
	 */
	private function windowSize( array $rescore ) {
		if ( isset( $rescore['window_size_override'] ) ) {
			$windowSize = $this->context->getConfig()->get( $rescore['window_size_override'] );
			if ( $windowSize !== null ) {
				return $windowSize;
			}
		}
		return $rescore['window'];
	}

	/**
	 * Inspect requested namespaces and return the supported profile
	 *
	 * @param array $profile
	 * @return array the supported rescore profile.
	 */
	private function getSupportedProfile( array $profile ) {
		if ( !is_array( $profile['supported_namespaces'] ) ) {
			switch ( $profile['supported_namespaces'] ) {
			case 'all':
				return $profile;
			case 'content':
				$profileNs = $this->context->getConfig()->get( 'ContentNamespaces' );
				break;
			default:
				throw new InvalidRescoreProfileException( "Invalid rescore profile: supported_namespaces should be 'all', 'content' or an array of namespaces" );
			}
		} else {
			$profileNs = $profile['supported_namespaces'];
		}

		if ( ! isset( $profile['fallback_profile'] ) ) {
			throw new InvalidRescoreProfileException( "Invalid rescore profile: fallback_profile is mandatory if supported_namespaces is not 'all'." );
		}

		$queryNs = $this->context->getNamespaces();

		if ( !$queryNs ) {
			// According to comments in Searcher if namespaces is
			// not set we run the query on all namespaces
			// @todo: verify comments.
			return $this->getFallbackProfile( $profile['fallback_profile'] );
		}

		foreach( $queryNs as $ns ) {
			if ( !in_array( $ns, $profileNs ) ) {
				return $this->getFallbackProfile( $profile['fallback_profile'] );
			}
		}
		return $profile;
	}

	/**
	 * @param string $profileName the profile to load
	 * @return array the rescore profile identified by $profileName
	 */
	private function getFallbackProfile( $profileName ) {
		$profile = $this->context->getConfig()->getElement( 'CirrusSearchRescoreProfiles', $profileName );
		if ( !$profile ) {
			throw new InvalidRescoreProfileException( "Unknown fallback profile $profileName." );
		}
		if ( $profile['supported_namespaces'] !== 'all' ) {
			throw new InvalidRescoreProfileException( "Fallback profile $profileName must support all namespaces." );
		}
		return $profile;
	}
}

class FunctionScoreChain {
	/**
	 * List of allowed function_score param
	 * we keep boost and boost_mode even if they do not make sense
	 * here since we do not allow to specify the query param.
	 * The query will be MatchAll with a score to 1.
	 *
	 * @var string[]
	 */
	private static $functionScoreParams = [
		'boost',
		'boost_mode',
		'max_boost',
		'score_mode',
		'min_score'
	];

	/**
	 * @var SearchContext
	 */
	private $context;

	/**
	 * @var FunctionScoreDecorator
	 */
	private $functionScore;

	/**
	 * @var array the function score chain
	 */
	private $chain;

	/**
	 * @var string the name of the chain
	 */
	private $chainName;

	/**
	 * Builds a new function score chain.
	 *
	 * @param SearchContext $context
	 * @param string $chainName the name of the chain (must be a valid
	 *  chain in wgCirrusSearchRescoreFunctionScoreChains)
	 */
	public function __construct( SearchContext $context, $chainName ) {
		$this->chainName = $chainName;
		$this->context = $context;
		$this->functionScore = new FunctionScoreDecorator();
		$this->chain = $context->getConfig()->getElement( 'CirrusSearchRescoreFunctionScoreChains', $chainName );
		if ( $this->chain === null ) {
			throw new InvalidRescoreProfileException( "Unknown rescore function chain $chainName" );
		}

		$params = array_intersect_key( $this->chain, array_flip( self::$functionScoreParams ) );
		foreach( $params as $param => $value ) {
			$this->functionScore->setParam( $param, $value );
		}
	}

	/**
	 * @return FunctionScore|null the rescore query or null none of functions were
	 *  needed.
	 */
	public function buildRescoreQuery() {
		if ( !isset( $this->chain['functions'] ) ) {
			throw new InvalidRescoreProfileException( "No functions defined in chain {$this->chainName}." );
		}
		foreach( $this->chain['functions'] as $func ) {
			$impl = $this->getImplementation( $func );
			$impl->append( $this->functionScore );
		}
		if ( !$this->functionScore->isEmptyFunction() ) {
			return $this->functionScore;
		}
		return null;
	}

	/**
	 * @param array $func
	 * @return FunctionScoreBuilder
	 */
	private function getImplementation( $func ) {
		$weight = isset ( $func['weight'] ) ? $func['weight'] : 1;
		switch( $func['type'] ) {
		case 'boostlinks':
			return new IncomingLinksFunctionScoreBuilder( $this->context, $weight );
		case 'recency':
			return new PreferRecentFunctionScoreBuilder( $this->context, $weight );
		case 'templates':
			return new BoostTemplatesFunctionScoreBuilder( $this->context, $weight );
		case 'namespaces':
			return new NamespacesFunctionScoreBuilder( $this->context, $weight );
		case 'language':
			return new LangWeightFunctionScoreBuilder( $this->context, $weight );
		case 'custom_field':
			return new CustomFieldFunctionScoreBuilder( $this->context, $weight, $func['params'] );
		case 'script':
			return new ScriptScoreFunctionScoreBuilder( $this->context, $weight, $func['script'] );
		case 'logscale_boost':
			return new LogScaleBoostFunctionScoreBuilder( $this->context, $weight,  $func['params'] );
		case 'satu':
			return new SatuFunctionScoreBuilder( $this->context, $weight,  $func['params'] );
		case 'log_multi':
			return new LogMultFunctionScoreBuilder( $this->context, $weight,  $func['params'] );
		case 'geomean':
			return new GeoMeanFunctionScoreBuilder( $this->context, $weight,  $func['params'] );
		case 'georadius':
			return new GeoRadiusFunctionScoreBuilder( $this->context, $weight );
		default:
			throw new InvalidRescoreProfileException( "Unknown function score type {$func['type']}." );
		}
	}
}

/**
 * This is useful to check if the function score is empty
 * Function score builders may not add any function if some
 * criteria are not met. If there's no function we should not
 * not build the rescore query.
 * @todo: find another pattern to deal with this problem and avoid
 * this strong dependency to FunctionScore::addFunction signature.
 */
class FunctionScoreDecorator extends FunctionScore {
	/** @var int */
	private $size = 0;

	/**
	 * @param string $functionType
	 * @param array|float $functionParams
	 * @param AbstractQuery|null $filter
	 * @param float|null $weight
	 * @return $this
	 */
	public function addFunction( $functionType, $functionParams, $filter = null, $weight = null ) {
		$this->size++;
		return parent::addFunction( $functionType, $functionParams, $filter, $weight );
	}

	/**
	 * @return boolean true if this function score is empty
	 */
	public function isEmptyFunction() {
		return $this->size == 0;
	}

	/**
	 * @return int the number of added functions.
	 */
	public function getSize() {
		return $this->size;
	}

	/**
	 * Default elastica behaviour is to use class name
	 * as property name. We must override this function
	 * to force the name to function_score
	 *
	 * @return string
	 */
	protected function _getBaseName() {
		return "function_score";
	}
}

abstract class FunctionScoreBuilder {
	/**
	 * @param SearchContext $context
	 */
	protected $context;

	/**
	 * @var float global weight of this function score builder
	 */
	protected $weight;

	/**
	 * @param SearchContext $context the search context
	 * @param float $weight the global weight
	 */
	public function __construct( SearchContext $context, $weight ) {
		$this->context = $context;
		$this->weight = $this->getOverriddenFactor( $weight );
	}

	/**
	 * Append functions to the function score $container
	 *
	 * @param FunctionScore $container
	 */
	public abstract function append( FunctionScore $container );

	/**
	 * Utility method to extract a factor (float) that can
	 * be overridden by a config value or an URI param
	 *
	 * @param float|array $value
	 * @return float
	 */
	protected function getOverriddenFactor( $value ) {
		if ( is_array( $value ) ) {
			$returnValue = (float) $value['value'];

			if ( isset( $value['config_override'] ) ) {
				// Override factor with config
				$fromConfig = $this->context->getConfig()->get( $value['config_override'] );
				if ( $fromConfig !== null ) {
					$returnValue = (float) $fromConfig;
				}
			}

			if ( isset( $value['uri_param_override'] ) ) {
				// Override factor with uri param
				$uriParam = $value['uri_param_override'];
				$request = \RequestContext::getMain()->getRequest();
				if ( $request ) {
					$fromUri = $request->getVal( $uriParam );
					if ( $fromUri !== null && is_numeric( $fromUri ) ) {
						$returnValue = (float) $fromUri;
					}
				}
			}
			return $returnValue;
		} else {
			return (float) $value;
		}
	}
}

/**
 * Builds a set of functions with boosted templates
 * Uses a weight function with a filter for each template.
 * The list of boosted templates is read from SearchContext
 */
class BoostTemplatesFunctionScoreBuilder extends FunctionScoreBuilder {
	/**
	 * @var float[] Template boost values keyed by template name
	 */
	private $boostTemplates;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 */
	public function __construct( SearchContext $context, $weight ) {
		parent::__construct( $context, $weight );
		// Use the boosted template from query string if available
		$this->boostTemplates = $context->getBoostTemplatesFromQuery();
		// empty array may be returned here in the case of a syntax error
		// @todo: verify that this is what we want: in case of a syntax error
		// we disable default boost templates.
		if ( $this->boostTemplates === null ) {
			// Fallback to default otherwise
			$this->boostTemplates =
				Util::getDefaultBoostTemplates( $context->getConfig() );
		}
	}

	public function append( FunctionScore $functionScore ) {
		if( !$this->boostTemplates ) {
			return;
		}
		foreach ( $this->boostTemplates as $name => $weight ) {
			$match = new \Elastica\Query\Match();
			$match->setFieldQuery( 'template', $name );
			$functionScore->addWeightFunction( $weight * $this->weight, $match );
		}
	}
}

/**
 * Builds a set of functions with namespaces.
 * Uses a weight function with a filter for each namespace.
 * Activated only if more than one namespace is requested.
 */
class NamespacesFunctionScoreBuilder extends FunctionScoreBuilder {
	/**
	 * @var null|float[] initialized version of $wgCirrusSearchNamespaceWeights with all string keys
	 * translated into integer namespace codes using $this->language.
	 */
	private $normalizedNamespaceWeights;

	/**
	 * @var int[] List of namespace id's
	 */
	private $namespacesToBoost;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 */
	public function __construct( SearchContext $context, $weight ) {
		parent::__construct( $context, $weight );
		$this->namespacesToBoost = $this->context->getNamespaces() ?: MWNamespace::getValidNamespaces();
		if ( !$this->namespacesToBoost || count( $this->namespacesToBoost ) == 1 ) {
			// nothing to boost, no need to initialize anything else.
			return;
		}
		$this->normalizedNamespaceWeights = [];
		$language = $this->context->getConfig()->get( 'ContLang' );
		foreach ( $this->context->getConfig()->get( 'CirrusSearchNamespaceWeights' ) as $ns => $weight ) {
			if ( is_string( $ns ) ) {
				$ns = $language->getNsIndex( $ns );
				// Ignore namespaces that don't exist.
				if ( $ns === false ) {
					continue;
				}
			}
			// Now $ns should always be an integer.
			$this->normalizedNamespaceWeights[ $ns ] = $weight;
		}
	}

	/**
	 * Get the weight of a namespace.
	 *
	 * @param int $namespace the namespace
	 * @return float the weight of the namespace
	 */
	private function getBoostForNamespace( $namespace ) {
		if ( isset( $this->normalizedNamespaceWeights[ $namespace ] ) ) {
			return $this->normalizedNamespaceWeights[ $namespace ];
		}
		if ( MWNamespace::isSubject( $namespace ) ) {
			if ( $namespace === NS_MAIN ) {
				return 1;
			}
			return $this->context->getConfig()->get( 'CirrusSearchDefaultNamespaceWeight' );
		}
		$subjectNs = MWNamespace::getSubject( $namespace );
		if ( isset( $this->normalizedNamespaceWeights[ $subjectNs ] ) ) {
			return $this->context->getConfig()->get( 'CirrusSearchTalkNamespaceWeight' ) * $this->normalizedNamespaceWeights[ $subjectNs ];
		}
		if ( $namespace === NS_TALK ) {
			return $this->context->getConfig()->get( 'CirrusSearchTalkNamespaceWeight' );
		}
		return $this->context->getConfig()->get( 'CirrusSearchDefaultNamespaceWeight' ) * $this->context->getConfig()->get( 'CirrusSearchTalkNamespaceWeight' );
	}

	public function append( FunctionScore $functionScore ) {
		if ( !$this->namespacesToBoost || count( $this->namespacesToBoost ) == 1 ) {
			// nothing to boost, no need to initialize anything else.
			return;
		}

		// first build the opposite map, this will allow us to add a
		// single factor function per weight by using a terms filter.
		$weightToNs = [];
		foreach( $this->namespacesToBoost as $ns ) {
			$weight = $this->getBoostForNamespace( $ns ) * $this->weight;
			$key = (string) $weight;
			if ( $key == '1' ) {
				// such weights would have no effect
				// we can ignore them.
				continue;
			}
			if ( !isset( $weightToNs[$key] ) ) {
				$weightToNs[$key] = [ $ns ];
			} else {
				$weightToNs[$key][] = $ns;
			}
		}
		foreach( $weightToNs as $weight => $namespaces ) {
			$filter = new \Elastica\Query\Terms( 'namespace', $namespaces );
			$functionScore->addWeightFunction( $weight, $filter );
		}
	}
}

/**
 * Builds a function that boosts incoming links
 * formula is log( incoming_links + 2 )
 */
class IncomingLinksFunctionScoreBuilder extends FunctionScoreBuilder {
	/**
	 * @param SearchContext $context
	 * @param float $weight
	 */
	public function __construct( SearchContext $context, $weight ) {
		parent::__construct( $context, $weight );
	}

	public function append( FunctionScore $functionScore ) {
		// Backward compat code, allows to disable this function
		// even if specified in the rescore profile
		if( !$this->context->isBoostLinks() ) {
			return;
		}
		$functionScore->addFunction( 'field_value_factor', [
			'field' => 'incoming_links',
			'modifier' => 'log2p',
			'missing' => 0,
		] );
	}
}

/**
 * Builds a function using a custom numeric field and
 * parameters attached to a profile.
 * Uses the function field_value_factor
 */
class CustomFieldFunctionScoreBuilder extends FunctionScoreBuilder {
	/**
	 * @var array the field_value_factor profile
	 */
	private $profile;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 * @param array $profile
	 */
	public function __construct( SearchContext $context, $weight, $profile ) {
		parent::__construct( $context, $weight );
		if ( isset ( $profile['factor'] ) ) {
			$profile['factor'] = $this->getOverriddenFactor( $profile['factor'] );
		}
		$this->profile = $profile;
	}

	public function append( FunctionScore $functionScore ) {
		if ( isset( $this->profile['factor'] ) && $this->profile['factor'] === 0.0 ) {
			// If factor is 0 this function score will have no impact.
			return;
		}
		$functionScore->addFunction( 'field_value_factor', $this->profile, null, $this->weight );
	}
}

/**
 * Normalize values in the [0,1] range
 * Allows to set:
 * - a scale
 * - midpoint
 * It will generate a log scale factor where :
 * - f(0) = 0
 * - f(scale) = 1
 * - f(midpoint) = 0.5
 *
 * Based on log10( a . x + 1 ) / log10( a . M + 1 )
 * a: a factor used to adjust the midpoint
 * M: the max value used to scale
 *
 */
class LogScaleBoostFunctionScoreBuilder extends FunctionScoreBuilder {
	/** @var string */
	private $field;
	/** @var int */
	private $impact;
	/** @var float */
	private $midpoint;
	/** @var float */
	private $scale;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 * @param array $profile
	 */
	public function __construct( SearchContext $context, $weight, $profile ) {
		parent::__construct( $context, $weight );

		if ( isset( $profile['midpoint'] ) ) {
			$this->midpoint = $this->getOverriddenFactor( $profile['midpoint'] );
		} else {
			throw new InvalidRescoreProfileException( 'midpoint is mandatory' );
		}

		if ( isset ( $profile['scale'] ) ) {
			$this->scale = $this->getOverriddenFactor( $profile['scale'] );
		} else {
			throw new InvalidRescoreProfileException( 'scale is mandatory' );
		}

		if ( isset ( $profile['field' ] ) ) {
			$this->field = $profile['field'];
		} else {
			throw new InvalidRescoreProfileException( 'field is mandatory' );
		}
	}

	/**
	 * find the factor to adjust the scale center,
	 * it's like finding the log base to have f(N) = 0.5
	 *
	 * @param float $M
	 * @param float $N
	 * @return float
	 */
	private function findCenterFactor( $M, $N ) {
		// Neutral point is found by resolving
		// log10( x . N + 1 ) / log10( x . M + 1 ) = 0.5
		// it's equivalent to resolving:
		// N²x² + (2N - M)x + 1 = 0
		// so we we use the quadratic formula:
		// (-(2N-M) + sqrt((2N-M)²-4N²)) / 2N²
		if ( 4*$N >= $M ) {
			throw new InvalidRescoreProfileException( 'The midpoint point cannot be higher than scale/4' );
		}
		return ( -( 2*$N - $M ) + sqrt( (2 * $N - $M) * (2 * $N - $M) - 4 * $N * $N ) ) / (2 * $N * $N);
	}

	public function append( FunctionScore $functionScore ) {
		if( $this->impact == 0 ) {
			return;
		}
		$formula = $this->getScript();

		$functionScore->addScriptScoreFunction( new \Elastica\Script\Script( $formula, null, 'expression' ), null, $this->weight );
	}

	/**
	 * @return string
	 */
	public function getScript() {
		$midFactor = $this->findCenterFactor( $this->scale, $this->midpoint );
		$formula = "log10($midFactor * min(doc['{$this->field}'].value,{$this->scale}) + 1)";
		$formula .= "/log10($midFactor * {$this->scale} + 1)";
		return $formula;
	}
}

/**
 * Saturation function based on x/(k+x), k is a parameter
 * to control how fast the function saturates.
 * NOTE: that satu is always 0.5 when x == k.
 * Parameter a is added to form a sigmoid : x^a/(k^a+x^a)
 * Based on http://research.microsoft.com/pubs/65239/craswell_sigir05.pdf
 * This function is suited to apply a new factor in a weighted sum.
 */
class SatuFunctionScoreBuilder extends FunctionScoreBuilder {
	/** @var float */
	private $k;
	/** @var float */
	private $a;
	/** @var string */
	private $field;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 * @param array $profile
	 */
	public function __construct( SearchContext $context, $weight, $profile ) {
		parent::__construct( $context, $weight );
		if ( isset( $profile['k'] ) ) {
			$this->k = $this->getOverriddenFactor( $profile['k'] );
			if ( $this->k <= 0 ) {
				throw new InvalidRescoreProfileException( 'Param k must be > 0' );
			}
		} else {
			throw new InvalidRescoreProfileException( 'Param k is mandatory' );
		}

		if ( isset( $profile['a'] ) ) {
			$this->a = $this->getOverriddenFactor( $profile['a'] );
			if ( $this->a <= 0 ) {
				throw new InvalidRescoreProfileException( 'Param a must be > 0' );
			}
		} else {
			$this->a = 1;
		}

		if ( isset( $profile['field'] ) ) {
			$this->field = $profile['field'];
		} else {
			throw new InvalidRescoreProfileException( 'Param field is mandatory' );
		}
	}

	public function append( FunctionScore $functionScore ) {
		$formula = $this->getScript();
		$functionScore->addScriptScoreFunction( new \Elastica\Script\Script( $formula, null, 'expression' ), null, $this->weight );
	}

	/**
	 * @return string
	 */
	public function getScript() {
		$formula = "pow(doc['{$this->field}'].value , {$this->a}) / ";
		$formula .= "( pow(doc['{$this->field}'].value, {$this->a}) + ";
		$formula .= "pow({$this->k},{$this->a}))";
		return $formula;
	}
}

/**
 * simple log(factor*field+2)^impact
 * Useful to control the impact when applied in a multiplication.
 */
class LogMultFunctionScoreBuilder extends FunctionScoreBuilder {
	/** @var float */
	private $impact;
	/** @var float */
	private $factor;
	/** @var string */
	private $field;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 * @param array $profile
	 */
	public function __construct( SearchContext $context, $weight, $profile ) {
		parent::__construct( $context, $weight );
		if ( isset( $profile['impact'] ) ) {
			$this->impact = $this->getOverriddenFactor( $profile['impact'] );
			if ( $this->impact <= 0 ) {
				throw new InvalidRescoreProfileException( 'Param impact must be > 0' );
			}
		} else {
			throw new InvalidRescoreProfileException( 'Param impact is mandatory' );
		}

		if ( isset( $profile['factor'] ) ) {
			$this->factor = $this->getOverriddenFactor( $profile['factor'] );
			if ( $this->factor <= 0 ) {
				throw new InvalidRescoreProfileException( 'Param factor must be > 0' );
			}
		} else {
			$this->factor = 1;
		}

		if ( isset( $profile['field'] ) ) {
			$this->field = $profile['field'];
		} else {
			throw new InvalidRescoreProfileException( 'Param field is mandatory' );
		}
	}

	public function append( FunctionScore $functionScore ) {
		$formula = "pow(log10({$this->factor} * doc['{$this->field}'].value + 2), {$this->impact})";
		$functionScore->addScriptScoreFunction( new \Elastica\Script\Script( $formula, null, 'expression' ), null, $this->weight );
	}
}

/**
 * Utility function to compute a weighted geometric mean.
 * According to https://en.wikipedia.org/wiki/Weighted_geometric_mean
 * this is equivalent to exp ( w1*ln(value1)+w2*ln(value2) / (w1 + w2) ) ^ impact
 * impact is applied as a power factor because this function is applied in a
 * multiplication.
 * Members can use only LogScaleBoostFunctionScoreBuilder or SatuFunctionScoreBuilder
 * these are the only functions that normalize the value in the [0,1] range.
 */
class GeoMeanFunctionScoreBuilder extends FunctionScoreBuilder {
	/** @var float */
	private $impact;
	/** @var array[] */
	private $scriptFunctions = [];
	/** @var float */
	private $epsilon = 0.0000001;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 * @param array $profile
	 */
	public function __construct( SearchContext $context, $weight, $profile ) {
		parent::__construct( $context, $weight );

		if ( isset( $profile['impact'] ) ) {
			$this->impact = $this->getOverriddenFactor( $profile['impact'] );
			if ( $this->impact <= 0 ) {
				throw new InvalidRescoreProfileException( 'Param impact must be > 0' );
			}
		} else {
			throw new InvalidRescoreProfileException( 'Param impact is mandatory' );
		}

		if ( isset( $profile['epsilon'] ) ) {
			$this->epsilon = $this->getOverriddenFactor( $profile['epsilon'] );
		}

		if ( !isset( $profile['members'] ) || !is_array( $profile['members'] ) ) {
			throw new InvalidRescoreProfileException( 'members must be an array of arrays' );
		}
		foreach( $profile['members'] as $member ) {
			if ( !is_array( $member ) ) {
				throw new InvalidRescoreProfileException( "members must be an array of arrays" );
			}
			if ( !isset( $member['weight'] ) ) {
				$weight = 1;
			} else {
				$weight = $this->getOverriddenFactor( $member['weight'] );
			}
			$function = [ 'weight' => $weight ];
			switch( $member['type'] ) {
			case 'satu':
				$function['script'] = new SatuFunctionScoreBuilder( $this->context, 1, $member['params'] );
				break;
			case 'logscale_boost':
				$function['script'] = new LogScaleBoostFunctionScoreBuilder( $this->context, 1, $member['params'] );
				break;
			default:
				throw new InvalidRescoreProfileException( "Unsupported function in {$member['type']}." );
			}
			$this->scriptFunctions[] = $function;
		}
		if ( count( $this->scriptFunctions ) < 2 ) {
			throw new InvalidRescoreProfileException( "At least 2 members are needed to compute a geometric mean." );
		}
	}

	/**
	 * Build a weighted geometric mean using a logarithmic arithmetic mean.
	 * exp(w1*ln(value1)+w2*ln(value2) / (w1+w2))
	 * NOTE: We need to use an epsilon value in case value is 0.
	 *
	 * @return string|null the script
	 */
	public function getScript() {
		$formula = "pow(";
		$formula .= "exp((";
		$first = true;
		$sumWeight = 0;
		foreach( $this->scriptFunctions as $func ) {
			if ( $first ) {
				$first = false;
			} else {
				$formula .= " + ";
			}
			$sumWeight += $func['weight'];
			$formula .= "{$func['weight']}*ln(max(";

			$formula .= $func['script']->getScript();

			$formula .= ", {$this->epsilon}))";
		}
		if ( $sumWeight == 0 ) {
			return null;
		}
		$formula .= ")";
		$formula .= "/ $sumWeight )";
		$formula .= ", {$this->impact})"; // pow(
		return $formula;
	}

	public function append( FunctionScore $functionScore ) {
		$formula = $this->getScript();
		if ( $formula != null ) {
			$functionScore->addScriptScoreFunction( new \Elastica\Script\Script( $formula, null, 'expression' ), null, $this->weight );
		}
	}
}

/**
 * Builds a script score boost documents on the timestamp field.
 * Reads its param from SearchContext: preferRecentDecayPortion and preferRecentHalfLife
 * Can be initialized by config for full text and by special syntax in user query
 */
class PreferRecentFunctionScoreBuilder extends FunctionScoreBuilder {
	public function append( FunctionScore $functionScore ) {
		if ( !$this->context->hasPreferRecentOptions() ) {
			return;
		}
		// Convert half life for time in days to decay constant for time in milliseconds.
		$decayConstant = log( 2 ) / $this->context->getPreferRecentHalfLife() / 86400000;
		$parameters = [
			'decayConstant' => $decayConstant,
			'decayPortion' => $this->context->getPreferRecentDecayPortion(),
			'nonDecayPortion' => 1 - $this->context->getPreferRecentDecayPortion(),
			'now' => time() * 1000
		];

		// e^ct where t is last modified time - now which is negative
		$exponentialDecayExpression = "exp(decayConstant * (doc['timestamp'].value - now))";
		if ( $this->context->getPreferRecentDecayPortion() !== 1.0 ) {
			$exponentialDecayExpression = "$exponentialDecayExpression * decayPortion + nonDecayPortion";
		}
		$functionScore->addScriptScoreFunction( new \Elastica\Script\Script( $exponentialDecayExpression,
			$parameters, 'expression' ), null, $this->weight );
	}
}

/**
 * Builds a boost for documents based on geocoordinates.
 * Reads its params from SearchContext::geoBoost. Initialized
 * by special syntax in user query.
 */
class GeoRadiusFunctionScoreBuilder extends FunctionScoreBuilder {
	public function append( FunctionScore $functionScore ) {
		foreach ( $this->context->getGeoBoosts() as $config ) {
			$functionScore->addWeightFunction(
				$this->weight * $config['weight'],
				GeoFeature::createQuery( $config['coord'], $config['radius'] )
			);
		}
	}
}

/**
 * Boosts documents in user language and in wiki language if different
 * Uses getUserLanguage in SearchConfig and LanguageCode for language values
 * and CirrusSearchLanguageWeight['user'|'wiki'] for respective weights.
 */
class LangWeightFunctionScoreBuilder extends FunctionScoreBuilder {
	/**
	 * @var string user language
	 */
	private $userLang;

	/**
	 * @var float user language weight
	 */
	private $userWeight;

	/**
	 * @var string wiki language
	 */
	private $wikiLang;

	/**
	 * @var float wiki language weight
	 */
	private $wikiWeight;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 */
	public function __construct( SearchContext $context, $weight ) {
		parent::__construct( $context, $weight );
		$this->userLang = $this->context->getConfig()->getUserLanguage();
		$this->userWeight = $this->context->getConfig()->getElement( 'CirrusSearchLanguageWeight', 'user' );
		$this->wikiLang = $this->context->getConfig()->get( 'LanguageCode' );
		$this->wikiWeight = $this->context->getConfig()->getElement( 'CirrusSearchLanguageWeight', 'wiki' );
	}

	public function append( FunctionScore $functionScore ) {
		// Boost pages in a user's language
		if ( $this->userWeight ) {
			$functionScore->addWeightFunction(
				$this->userWeight * $this->weight,
				new \Elastica\Query\Term( [ 'language' => $this->userLang ] )
			);
		}

		// And a wiki's language, if it's different
		if ( $this->wikiWeight && $this->userLang != $this->wikiLang ) {
			$functionScore->addWeightFunction(
				$this->wikiWeight * $this->weight,
				new \Elastica\Query\Term( [ 'language' => $this->wikiLang ] )
			);
		}
	}
}

/**
 * A function score that builds a script_score.
 * see: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#function-script-score
 * NOTE: only lucene expression script engine is supported.
 */
class ScriptScoreFunctionScoreBuilder extends FunctionScoreBuilder {
	/**
	 * @var string the script
	 */
	private $script;

	/**
	 * @param SearchContext $context
	 * @param float $weight
	 * @param string $script
	 */
	public function __construct( SearchContext $context, $weight, $script ) {
		parent::__construct( $context, $weight );
		$this->script = $script;
	}

	public function append( FunctionScore $functionScore ) {
		$functionScore->addScriptScoreFunction(
			new \Elastica\Script\Script( $this->script, null, 'expression' ),
			null, $this->weight );
	}
}

/**
 * Exception thrown if an error has been detected in the rescore profiles
 */
class InvalidRescoreProfileException extends \Exception {
}

Zerion Mini Shell 1.0